diff --git a/veza-backend-api/internal/api/routes_core.go b/veza-backend-api/internal/api/routes_core.go index a10f89bdb..4175bdae6 100644 --- a/veza-backend-api/internal/api/routes_core.go +++ b/veza-backend-api/internal/api/routes_core.go @@ -436,6 +436,12 @@ func (r *APIRouter) setupCoreProtectedRoutes(v1 *gin.RouterGroup) { admin.POST("/announcements", announcementHandler.Create) admin.DELETE("/announcements/:id", announcementHandler.Delete) + // v0.803 ADM1-05: Feature flags CRUD + featureFlagSvc := services.NewFeatureFlagService(r.db.GormDB, r.logger) + featureFlagHandler := handlers.NewFeatureFlagHandler(featureFlagSvc) + admin.GET("/feature-flags", featureFlagHandler.List) + admin.PUT("/feature-flags/:name", featureFlagHandler.Toggle) + // v0.701: Admin Transfer Dashboard var adminTransferHandler *handlers.AdminTransferHandler if r.config != nil && r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" { diff --git a/veza-backend-api/internal/handlers/feature_flag_handler.go b/veza-backend-api/internal/handlers/feature_flag_handler.go new file mode 100644 index 000000000..aa574c0d5 --- /dev/null +++ b/veza-backend-api/internal/handlers/feature_flag_handler.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "veza-backend-api/internal/services" +) + +// FeatureFlagHandler handles feature flag endpoints (v0.803 ADM1-05) +type FeatureFlagHandler struct { + svc *services.FeatureFlagService +} + +// NewFeatureFlagHandler creates a new FeatureFlagHandler +func NewFeatureFlagHandler(svc *services.FeatureFlagService) *FeatureFlagHandler { + return &FeatureFlagHandler{svc: svc} +} + +// List returns all feature flags (admin) +func (h *FeatureFlagHandler) List(c *gin.Context) { + list, err := h.svc.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list feature flags"}) + return + } + c.JSON(http.StatusOK, gin.H{"feature_flags": list}) +} + +// Toggle enables or disables a feature flag (admin) +func (h *FeatureFlagHandler) Toggle(c *gin.Context) { + name := c.Param("name") + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + var req struct { + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "enabled is required"}) + return + } + + flag, err := h.svc.Toggle(c.Request.Context(), name, req.Enabled) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, flag) +} diff --git a/veza-backend-api/internal/models/feature_flag.go b/veza-backend-api/internal/models/feature_flag.go new file mode 100644 index 000000000..35e927155 --- /dev/null +++ b/veza-backend-api/internal/models/feature_flag.go @@ -0,0 +1,18 @@ +package models + +import ( + "time" +) + +// FeatureFlag represents a feature flag (v0.803 ADM1-05) +type FeatureFlag struct { + Name string `gorm:"primaryKey;size:100" json:"name"` + Enabled bool `gorm:"not null;default:false" json:"enabled"` + Description string `gorm:"type:text" json:"description,omitempty"` + UpdatedAt time.Time `gorm:"not null" json:"updated_at"` +} + +// TableName returns the table name +func (FeatureFlag) TableName() string { + return "feature_flags" +} diff --git a/veza-backend-api/internal/services/feature_flag_service.go b/veza-backend-api/internal/services/feature_flag_service.go new file mode 100644 index 000000000..dc1febb59 --- /dev/null +++ b/veza-backend-api/internal/services/feature_flag_service.go @@ -0,0 +1,49 @@ +package services + +import ( + "context" + "fmt" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "veza-backend-api/internal/models" +) + +// FeatureFlagService handles feature flags (v0.803 ADM1-05) +type FeatureFlagService struct { + db *gorm.DB + logger *zap.Logger +} + +// NewFeatureFlagService creates a new FeatureFlagService +func NewFeatureFlagService(db *gorm.DB, logger *zap.Logger) *FeatureFlagService { + return &FeatureFlagService{db: db, logger: logger} +} + +// List returns all feature flags +func (s *FeatureFlagService) List(ctx context.Context) ([]models.FeatureFlag, error) { + var list []models.FeatureFlag + if err := s.db.WithContext(ctx).Order("name ASC").Find(&list).Error; err != nil { + return nil, fmt.Errorf("failed to list feature flags: %w", err) + } + return list, nil +} + +// Toggle enables or disables a feature flag by name +func (s *FeatureFlagService) Toggle(ctx context.Context, name string, enabled bool) (*models.FeatureFlag, error) { + var flag models.FeatureFlag + if err := s.db.WithContext(ctx).Where("name = ?", name).First(&flag).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("feature flag not found: %s", name) + } + return nil, fmt.Errorf("failed to get feature flag: %w", err) + } + flag.Enabled = enabled + flag.UpdatedAt = time.Now() + if err := s.db.WithContext(ctx).Save(&flag).Error; err != nil { + return nil, fmt.Errorf("failed to update feature flag: %w", err) + } + return &flag, nil +} diff --git a/veza-backend-api/migrations/935_feature_flags.sql b/veza-backend-api/migrations/935_feature_flags.sql new file mode 100644 index 000000000..575771b65 --- /dev/null +++ b/veza-backend-api/migrations/935_feature_flags.sql @@ -0,0 +1,14 @@ +-- v0.803 ADM1-05: Feature flags with DB persistence +CREATE TABLE IF NOT EXISTS feature_flags ( + name VARCHAR(100) PRIMARY KEY, + enabled BOOLEAN NOT NULL DEFAULT false, + description TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO feature_flags (name, enabled, description) VALUES + ('HLS_STREAMING', true, 'Enable HLS streaming'), + ('ROLE_MANAGEMENT', true, 'Enable role management'), + ('PLAYLIST_SHARE', true, 'Enable playlist sharing'), + ('PLAYLIST_RECOMMENDATIONS', true, 'Enable playlist recommendations') +ON CONFLICT (name) DO NOTHING;