Add IsFree subscription toggle to bypass all tier limitations

- Add IsFree boolean field to UserSubscription model
- When IsFree is true, user sees limitations_enabled=false regardless of global setting
- CheckLimit() bypasses all limit checks for IsFree users
- Add admin endpoint GET /api/admin/subscriptions/user/:user_id
- Add IsFree toggle to admin user detail page under Subscription card
- Add database migration 004_subscription_is_free
- Add integration tests for IsFree functionality
- Add task kanban categorization documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-01 18:05:41 -06:00
parent 0a708c092d
commit 0c86611a10
14 changed files with 750 additions and 3 deletions

View File

@@ -236,6 +236,7 @@ type SubscriptionFilters struct {
type UpdateSubscriptionRequest struct {
Tier *string `json:"tier" binding:"omitempty,oneof=free premium pro"`
AutoRenew *bool `json:"auto_renew"`
IsFree *bool `json:"is_free"`
Platform *string `json:"platform" binding:"omitempty,max=20"`
SubscribedAt *string `json:"subscribed_at"`
ExpiresAt *string `json:"expires_at"`

View File

@@ -258,6 +258,7 @@ type SubscriptionResponse struct {
Tier string `json:"tier"`
Platform string `json:"platform"`
AutoRenew bool `json:"auto_renew"`
IsFree bool `json:"is_free"`
SubscribedAt *string `json:"subscribed_at,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
CancelledAt *string `json:"cancelled_at,omitempty"`

View File

@@ -143,6 +143,9 @@ func (h *AdminSubscriptionHandler) Update(c *gin.Context) {
if req.AutoRenew != nil {
subscription.AutoRenew = *req.AutoRenew
}
if req.IsFree != nil {
subscription.IsFree = *req.IsFree
}
if err := h.db.Save(&subscription).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscription"})
@@ -153,6 +156,44 @@ func (h *AdminSubscriptionHandler) Update(c *gin.Context) {
c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription))
}
// GetByUser handles GET /api/admin/subscriptions/user/:user_id
func (h *AdminSubscriptionHandler) GetByUser(c *gin.Context) {
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
var subscription models.UserSubscription
err = h.db.
Preload("User").
Where("user_id = ?", userID).
First(&subscription).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// Create a default subscription for the user
subscription = models.UserSubscription{
UserID: uint(userID),
Tier: models.TierFree,
AutoRenew: true,
IsFree: false,
}
if err := h.db.Create(&subscription).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create subscription"})
return
}
// Reload with user
h.db.Preload("User").First(&subscription, subscription.ID)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscription"})
return
}
}
c.JSON(http.StatusOK, h.toSubscriptionResponse(&subscription))
}
// GetStats handles GET /api/admin/subscriptions/stats
func (h *AdminSubscriptionHandler) GetStats(c *gin.Context) {
var total, free, premium, pro int64
@@ -177,6 +218,7 @@ func (h *AdminSubscriptionHandler) toSubscriptionResponse(sub *models.UserSubscr
Tier: string(sub.Tier),
Platform: sub.Platform,
AutoRenew: sub.AutoRenew,
IsFree: sub.IsFree,
CreatedAt: sub.CreatedAt.Format("2006-01-02T15:04:05Z"),
}

View File

@@ -142,6 +142,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
{
subscriptions.GET("", subscriptionHandler.List)
subscriptions.GET("/stats", subscriptionHandler.GetStats)
subscriptions.GET("/user/:user_id", subscriptionHandler.GetByUser)
subscriptions.GET("/:id", subscriptionHandler.Get)
subscriptions.PUT("/:id", subscriptionHandler.Update)
}