Add multi-image support for task completions and documents
- Add TaskCompletionImage and DocumentImage models with one-to-many relationships
- Update admin panel to display images for completions and documents
- Add image arrays to API request/response DTOs
- Update repositories with Preload("Images") for eager loading
- Fix seed SQL execution to use raw SQL instead of prepared statements
- Fix table names in seed file (admin_users, push_notifications_*)
- Add comprehensive seed test data with 34 completion images and 24 document images
- Add subscription limitations admin feature with toggle
- Update admin sidebar with limitations link
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -152,21 +152,29 @@ type ContractorDetailResponse struct {
|
||||
TaskCount int `json:"task_count"`
|
||||
}
|
||||
|
||||
// DocumentImageResponse represents a document image
|
||||
type DocumentImageResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Caption string `json:"caption"`
|
||||
}
|
||||
|
||||
// DocumentResponse represents a document in admin responses
|
||||
type DocumentResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ResidenceID uint `json:"residence_id"`
|
||||
ResidenceName string `json:"residence_name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DocumentType string `json:"document_type"`
|
||||
FileName string `json:"file_name"`
|
||||
FileURL string `json:"file_url"`
|
||||
Vendor string `json:"vendor"`
|
||||
ExpiryDate *string `json:"expiry_date,omitempty"`
|
||||
PurchaseDate *string `json:"purchase_date,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID uint `json:"id"`
|
||||
ResidenceID uint `json:"residence_id"`
|
||||
ResidenceName string `json:"residence_name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
DocumentType string `json:"document_type"`
|
||||
FileName string `json:"file_name"`
|
||||
FileURL string `json:"file_url"`
|
||||
Vendor string `json:"vendor"`
|
||||
ExpiryDate *string `json:"expiry_date,omitempty"`
|
||||
PurchaseDate *string `json:"purchase_date,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Images []DocumentImageResponse `json:"images"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// DocumentDetailResponse includes more details for single document view
|
||||
|
||||
@@ -22,20 +22,28 @@ func NewAdminCompletionHandler(db *gorm.DB) *AdminCompletionHandler {
|
||||
return &AdminCompletionHandler{db: db}
|
||||
}
|
||||
|
||||
// CompletionImageResponse represents an image in a completion
|
||||
type CompletionImageResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Caption string `json:"caption"`
|
||||
}
|
||||
|
||||
// CompletionResponse represents a task completion in API responses
|
||||
type CompletionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
TaskTitle string `json:"task_title"`
|
||||
ResidenceID uint `json:"residence_id"`
|
||||
ResidenceName string `json:"residence_name"`
|
||||
CompletedByID uint `json:"completed_by_id"`
|
||||
CompletedBy string `json:"completed_by"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *string `json:"actual_cost"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
TaskTitle string `json:"task_title"`
|
||||
ResidenceID uint `json:"residence_id"`
|
||||
ResidenceName string `json:"residence_name"`
|
||||
CompletedByID uint `json:"completed_by_id"`
|
||||
CompletedBy string `json:"completed_by"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *string `json:"actual_cost"`
|
||||
Rating *int `json:"rating"`
|
||||
Images []CompletionImageResponse `json:"images"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CompletionFilters extends PaginationParams with completion-specific filters
|
||||
@@ -60,7 +68,8 @@ func (h *AdminCompletionHandler) List(c *gin.Context) {
|
||||
query := h.db.Model(&models.TaskCompletion{}).
|
||||
Preload("Task").
|
||||
Preload("Task.Residence").
|
||||
Preload("CompletedBy")
|
||||
Preload("CompletedBy").
|
||||
Preload("Images")
|
||||
|
||||
// Apply search
|
||||
if filters.Search != "" {
|
||||
@@ -125,7 +134,7 @@ func (h *AdminCompletionHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
var completion models.TaskCompletion
|
||||
if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").First(&completion, id).Error; err != nil {
|
||||
if err := h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Completion not found"})
|
||||
return
|
||||
@@ -229,7 +238,7 @@ func (h *AdminCompletionHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").First(&completion, id)
|
||||
h.db.Preload("Task").Preload("Task.Residence").Preload("CompletedBy").Preload("Images").First(&completion, id)
|
||||
c.JSON(http.StatusOK, h.toCompletionResponse(&completion))
|
||||
}
|
||||
|
||||
@@ -241,7 +250,8 @@ func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCom
|
||||
CompletedByID: completion.CompletedByID,
|
||||
CompletedAt: completion.CompletedAt.Format("2006-01-02T15:04:05Z"),
|
||||
Notes: completion.Notes,
|
||||
PhotoURL: completion.PhotoURL,
|
||||
Rating: completion.Rating,
|
||||
Images: make([]CompletionImageResponse, 0),
|
||||
CreatedAt: completion.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
@@ -262,5 +272,14 @@ func (h *AdminCompletionHandler) toCompletionResponse(completion *models.TaskCom
|
||||
response.ActualCost = &cost
|
||||
}
|
||||
|
||||
// Convert images
|
||||
for _, img := range completion.Images {
|
||||
response.Images = append(response.Images, CompletionImageResponse{
|
||||
ID: img.ID,
|
||||
ImageURL: img.ImageURL,
|
||||
Caption: img.Caption,
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ func (h *AdminDocumentHandler) List(c *gin.Context) {
|
||||
|
||||
query := h.db.Model(&models.Document{}).
|
||||
Preload("Residence").
|
||||
Preload("CreatedBy")
|
||||
Preload("CreatedBy").
|
||||
Preload("Images")
|
||||
|
||||
// Apply search
|
||||
if filters.Search != "" {
|
||||
@@ -98,6 +99,7 @@ func (h *AdminDocumentHandler) Get(c *gin.Context) {
|
||||
Preload("Residence").
|
||||
Preload("CreatedBy").
|
||||
Preload("Task").
|
||||
Preload("Images").
|
||||
First(&document, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||
@@ -166,7 +168,7 @@ func (h *AdminDocumentHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.db.Preload("Residence").Preload("CreatedBy").First(&document, id)
|
||||
h.db.Preload("Residence").Preload("CreatedBy").Preload("Images").First(&document, id)
|
||||
c.JSON(http.StatusOK, h.toDocumentResponse(&document))
|
||||
}
|
||||
|
||||
@@ -236,7 +238,7 @@ func (h *AdminDocumentHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
h.db.Preload("Residence").Preload("CreatedBy").First(&document, document.ID)
|
||||
h.db.Preload("Residence").Preload("CreatedBy").Preload("Images").First(&document, document.ID)
|
||||
c.JSON(http.StatusCreated, h.toDocumentResponse(&document))
|
||||
}
|
||||
|
||||
@@ -296,6 +298,7 @@ func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.Docu
|
||||
FileURL: doc.FileURL,
|
||||
Vendor: doc.Vendor,
|
||||
IsActive: doc.IsActive,
|
||||
Images: make([]dto.DocumentImageResponse, 0),
|
||||
CreatedAt: doc.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
|
||||
@@ -311,5 +314,14 @@ func (h *AdminDocumentHandler) toDocumentResponse(doc *models.Document) dto.Docu
|
||||
response.PurchaseDate = &purchaseDate
|
||||
}
|
||||
|
||||
// Convert images
|
||||
for _, img := range doc.Images {
|
||||
response.Images = append(response.Images, dto.DocumentImageResponse{
|
||||
ID: img.ID,
|
||||
ImageURL: img.ImageURL,
|
||||
Caption: img.Caption,
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
480
internal/admin/handlers/limitations_handler.go
Normal file
480
internal/admin/handlers/limitations_handler.go
Normal file
@@ -0,0 +1,480 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
)
|
||||
|
||||
// AdminLimitationsHandler handles subscription limitations management
|
||||
type AdminLimitationsHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminLimitationsHandler creates a new handler
|
||||
func NewAdminLimitationsHandler(db *gorm.DB) *AdminLimitationsHandler {
|
||||
return &AdminLimitationsHandler{db: db}
|
||||
}
|
||||
|
||||
// === Settings (enable_limitations) ===
|
||||
|
||||
// LimitationsSettingsResponse represents the limitations settings
|
||||
type LimitationsSettingsResponse struct {
|
||||
EnableLimitations bool `json:"enable_limitations"`
|
||||
}
|
||||
|
||||
// GetSettings handles GET /api/admin/limitations/settings
|
||||
func (h *AdminLimitationsHandler) GetSettings(c *gin.Context) {
|
||||
var settings models.SubscriptionSettings
|
||||
if err := h.db.First(&settings, 1).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create default settings
|
||||
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false}
|
||||
h.db.Create(&settings)
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LimitationsSettingsResponse{
|
||||
EnableLimitations: settings.EnableLimitations,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateSettingsRequest represents the update request
|
||||
type UpdateLimitationsSettingsRequest struct {
|
||||
EnableLimitations *bool `json:"enable_limitations"`
|
||||
}
|
||||
|
||||
// UpdateSettings handles PUT /api/admin/limitations/settings
|
||||
func (h *AdminLimitationsHandler) UpdateSettings(c *gin.Context) {
|
||||
var req UpdateLimitationsSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var settings models.SubscriptionSettings
|
||||
if err := h.db.First(&settings, 1).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
settings = models.SubscriptionSettings{ID: 1}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.EnableLimitations != nil {
|
||||
settings.EnableLimitations = *req.EnableLimitations
|
||||
}
|
||||
|
||||
if err := h.db.Save(&settings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, LimitationsSettingsResponse{
|
||||
EnableLimitations: settings.EnableLimitations,
|
||||
})
|
||||
}
|
||||
|
||||
// === Tier Limits ===
|
||||
|
||||
// TierLimitsResponse represents tier limits in API response
|
||||
type TierLimitsResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Tier string `json:"tier"`
|
||||
PropertiesLimit *int `json:"properties_limit"`
|
||||
TasksLimit *int `json:"tasks_limit"`
|
||||
ContractorsLimit *int `json:"contractors_limit"`
|
||||
DocumentsLimit *int `json:"documents_limit"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toTierLimitsResponse(t *models.TierLimits) TierLimitsResponse {
|
||||
return TierLimitsResponse{
|
||||
ID: t.ID,
|
||||
Tier: string(t.Tier),
|
||||
PropertiesLimit: t.PropertiesLimit,
|
||||
TasksLimit: t.TasksLimit,
|
||||
ContractorsLimit: t.ContractorsLimit,
|
||||
DocumentsLimit: t.DocumentsLimit,
|
||||
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// ListTierLimits handles GET /api/admin/limitations/tier-limits
|
||||
func (h *AdminLimitationsHandler) ListTierLimits(c *gin.Context) {
|
||||
var limits []models.TierLimits
|
||||
if err := h.db.Order("tier").Find(&limits).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
|
||||
return
|
||||
}
|
||||
|
||||
// If no limits exist, create defaults
|
||||
if len(limits) == 0 {
|
||||
freeLimits := models.GetDefaultFreeLimits()
|
||||
proLimits := models.GetDefaultProLimits()
|
||||
h.db.Create(&freeLimits)
|
||||
h.db.Create(&proLimits)
|
||||
limits = []models.TierLimits{freeLimits, proLimits}
|
||||
}
|
||||
|
||||
responses := make([]TierLimitsResponse, len(limits))
|
||||
for i, l := range limits {
|
||||
responses[i] = toTierLimitsResponse(&l)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": responses,
|
||||
"total": len(responses),
|
||||
})
|
||||
}
|
||||
|
||||
// GetTierLimits handles GET /api/admin/limitations/tier-limits/:tier
|
||||
func (h *AdminLimitationsHandler) GetTierLimits(c *gin.Context) {
|
||||
tier := c.Param("tier")
|
||||
if tier != "free" && tier != "pro" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tier. Must be 'free' or 'pro'"})
|
||||
return
|
||||
}
|
||||
|
||||
var limits models.TierLimits
|
||||
if err := h.db.Where("tier = ?", tier).First(&limits).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create default
|
||||
if tier == "free" {
|
||||
limits = models.GetDefaultFreeLimits()
|
||||
} else {
|
||||
limits = models.GetDefaultProLimits()
|
||||
}
|
||||
h.db.Create(&limits)
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toTierLimitsResponse(&limits))
|
||||
}
|
||||
|
||||
// UpdateTierLimitsRequest represents the update request for tier limits
|
||||
type UpdateTierLimitsRequest struct {
|
||||
PropertiesLimit *int `json:"properties_limit"`
|
||||
TasksLimit *int `json:"tasks_limit"`
|
||||
ContractorsLimit *int `json:"contractors_limit"`
|
||||
DocumentsLimit *int `json:"documents_limit"`
|
||||
}
|
||||
|
||||
// UpdateTierLimits handles PUT /api/admin/limitations/tier-limits/:tier
|
||||
func (h *AdminLimitationsHandler) UpdateTierLimits(c *gin.Context) {
|
||||
tier := c.Param("tier")
|
||||
if tier != "free" && tier != "pro" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid tier. Must be 'free' or 'pro'"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateTierLimitsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var limits models.TierLimits
|
||||
if err := h.db.Where("tier = ?", tier).First(&limits).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new entry
|
||||
limits = models.TierLimits{Tier: models.SubscriptionTier(tier)}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tier limits"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields - note: we need to handle nil vs zero difference
|
||||
// A nil pointer in the request means "don't change"
|
||||
// The actual limit value can be nil (unlimited) or a number
|
||||
limits.PropertiesLimit = req.PropertiesLimit
|
||||
limits.TasksLimit = req.TasksLimit
|
||||
limits.ContractorsLimit = req.ContractorsLimit
|
||||
limits.DocumentsLimit = req.DocumentsLimit
|
||||
|
||||
if err := h.db.Save(&limits).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update tier limits"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toTierLimitsResponse(&limits))
|
||||
}
|
||||
|
||||
// === Upgrade Triggers ===
|
||||
|
||||
// UpgradeTriggerResponse represents an upgrade trigger in API response
|
||||
type UpgradeTriggerResponse struct {
|
||||
ID uint `json:"id"`
|
||||
TriggerKey string `json:"trigger_key"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
PromoHTML string `json:"promo_html"`
|
||||
ButtonText string `json:"button_text"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toUpgradeTriggerResponse(t *models.UpgradeTrigger) UpgradeTriggerResponse {
|
||||
return UpgradeTriggerResponse{
|
||||
ID: t.ID,
|
||||
TriggerKey: t.TriggerKey,
|
||||
Title: t.Title,
|
||||
Message: t.Message,
|
||||
PromoHTML: t.PromoHTML,
|
||||
ButtonText: t.ButtonText,
|
||||
IsActive: t.IsActive,
|
||||
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
// Available trigger keys
|
||||
var availableTriggerKeys = []string{
|
||||
"user_profile",
|
||||
"add_second_property",
|
||||
"add_11th_task",
|
||||
"view_contractors",
|
||||
"view_documents",
|
||||
}
|
||||
|
||||
// GetAvailableTriggerKeys handles GET /api/admin/limitations/upgrade-triggers/keys
|
||||
func (h *AdminLimitationsHandler) GetAvailableTriggerKeys(c *gin.Context) {
|
||||
type KeyOption struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
keys := []KeyOption{
|
||||
{Key: "user_profile", Label: "User Profile"},
|
||||
{Key: "add_second_property", Label: "Add Second Property"},
|
||||
{Key: "add_11th_task", Label: "Add 11th Task"},
|
||||
{Key: "view_contractors", Label: "View Contractors"},
|
||||
{Key: "view_documents", Label: "View Documents & Warranties"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// ListUpgradeTriggers handles GET /api/admin/limitations/upgrade-triggers
|
||||
func (h *AdminLimitationsHandler) ListUpgradeTriggers(c *gin.Context) {
|
||||
var triggers []models.UpgradeTrigger
|
||||
if err := h.db.Order("trigger_key").Find(&triggers).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade triggers"})
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]UpgradeTriggerResponse, len(triggers))
|
||||
for i, t := range triggers {
|
||||
responses[i] = toUpgradeTriggerResponse(&t)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": responses,
|
||||
"total": len(responses),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUpgradeTrigger handles GET /api/admin/limitations/upgrade-triggers/:id
|
||||
func (h *AdminLimitationsHandler) GetUpgradeTrigger(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var trigger models.UpgradeTrigger
|
||||
if err := h.db.First(&trigger, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toUpgradeTriggerResponse(&trigger))
|
||||
}
|
||||
|
||||
// CreateUpgradeTriggerRequest represents the create request
|
||||
type CreateUpgradeTriggerRequest struct {
|
||||
TriggerKey string `json:"trigger_key" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
PromoHTML string `json:"promo_html"`
|
||||
ButtonText string `json:"button_text"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// CreateUpgradeTrigger handles POST /api/admin/limitations/upgrade-triggers
|
||||
func (h *AdminLimitationsHandler) CreateUpgradeTrigger(c *gin.Context) {
|
||||
var req CreateUpgradeTriggerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate trigger key
|
||||
validKey := false
|
||||
for _, k := range availableTriggerKeys {
|
||||
if k == req.TriggerKey {
|
||||
validKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validKey {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid trigger_key"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if trigger key already exists
|
||||
var existing models.UpgradeTrigger
|
||||
if err := h.db.Where("trigger_key = ?", req.TriggerKey).First(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trigger key already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
trigger := models.UpgradeTrigger{
|
||||
TriggerKey: req.TriggerKey,
|
||||
Title: req.Title,
|
||||
Message: req.Message,
|
||||
PromoHTML: req.PromoHTML,
|
||||
ButtonText: req.ButtonText,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if req.ButtonText == "" {
|
||||
trigger.ButtonText = "Upgrade to Pro"
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
trigger.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.db.Create(&trigger).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upgrade trigger"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, toUpgradeTriggerResponse(&trigger))
|
||||
}
|
||||
|
||||
// UpdateUpgradeTriggerRequest represents the update request
|
||||
type UpdateUpgradeTriggerRequest struct {
|
||||
TriggerKey *string `json:"trigger_key"`
|
||||
Title *string `json:"title"`
|
||||
Message *string `json:"message"`
|
||||
PromoHTML *string `json:"promo_html"`
|
||||
ButtonText *string `json:"button_text"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// UpdateUpgradeTrigger handles PUT /api/admin/limitations/upgrade-triggers/:id
|
||||
func (h *AdminLimitationsHandler) UpdateUpgradeTrigger(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var trigger models.UpgradeTrigger
|
||||
if err := h.db.First(&trigger, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateUpgradeTriggerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.TriggerKey != nil {
|
||||
// Validate new trigger key
|
||||
validKey := false
|
||||
for _, k := range availableTriggerKeys {
|
||||
if k == *req.TriggerKey {
|
||||
validKey = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validKey {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid trigger_key"})
|
||||
return
|
||||
}
|
||||
// Check if key is already used by another trigger
|
||||
if *req.TriggerKey != trigger.TriggerKey {
|
||||
var existing models.UpgradeTrigger
|
||||
if err := h.db.Where("trigger_key = ?", *req.TriggerKey).First(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trigger key already exists"})
|
||||
return
|
||||
}
|
||||
}
|
||||
trigger.TriggerKey = *req.TriggerKey
|
||||
}
|
||||
if req.Title != nil {
|
||||
trigger.Title = *req.Title
|
||||
}
|
||||
if req.Message != nil {
|
||||
trigger.Message = *req.Message
|
||||
}
|
||||
if req.PromoHTML != nil {
|
||||
trigger.PromoHTML = *req.PromoHTML
|
||||
}
|
||||
if req.ButtonText != nil {
|
||||
trigger.ButtonText = *req.ButtonText
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
trigger.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.db.Save(&trigger).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update upgrade trigger"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, toUpgradeTriggerResponse(&trigger))
|
||||
}
|
||||
|
||||
// DeleteUpgradeTrigger handles DELETE /api/admin/limitations/upgrade-triggers/:id
|
||||
func (h *AdminLimitationsHandler) DeleteUpgradeTrigger(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var trigger models.UpgradeTrigger
|
||||
if err := h.db.First(&trigger, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Upgrade trigger not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch upgrade trigger"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&trigger).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete upgrade trigger"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Upgrade trigger deleted"})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -125,17 +126,29 @@ func (h *AdminSettingsHandler) runSeedFile(filename string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the underlying *sql.DB to execute raw SQL without prepared statements
|
||||
sqlDB, err := h.db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Split SQL into individual statements and execute each one
|
||||
// This is needed because GORM/PostgreSQL prepared statements don't support multiple commands
|
||||
statements := splitSQLStatements(string(sqlContent))
|
||||
|
||||
for _, stmt := range statements {
|
||||
for i, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if err := h.db.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
// Use the raw sql.DB to avoid GORM's prepared statement handling
|
||||
if _, err := sqlDB.Exec(stmt); err != nil {
|
||||
// Include statement number and first 100 chars for debugging
|
||||
preview := stmt
|
||||
if len(preview) > 100 {
|
||||
preview = preview[:100] + "..."
|
||||
}
|
||||
return fmt.Errorf("statement %d failed: %v\nStatement: %s", i+1, err, preview)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,6 +259,28 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
||||
settings.POST("/seed-lookups", settingsHandler.SeedLookups)
|
||||
settings.POST("/seed-test-data", settingsHandler.SeedTestData)
|
||||
}
|
||||
|
||||
// Limitations management (tier limits, upgrade triggers)
|
||||
limitationsHandler := handlers.NewAdminLimitationsHandler(db)
|
||||
limitations := protected.Group("/limitations")
|
||||
{
|
||||
// Settings (enable_limitations toggle)
|
||||
limitations.GET("/settings", limitationsHandler.GetSettings)
|
||||
limitations.PUT("/settings", limitationsHandler.UpdateSettings)
|
||||
|
||||
// Tier Limits
|
||||
limitations.GET("/tier-limits", limitationsHandler.ListTierLimits)
|
||||
limitations.GET("/tier-limits/:tier", limitationsHandler.GetTierLimits)
|
||||
limitations.PUT("/tier-limits/:tier", limitationsHandler.UpdateTierLimits)
|
||||
|
||||
// Upgrade Triggers
|
||||
limitations.GET("/upgrade-triggers/keys", limitationsHandler.GetAvailableTriggerKeys)
|
||||
limitations.GET("/upgrade-triggers", limitationsHandler.ListUpgradeTriggers)
|
||||
limitations.POST("/upgrade-triggers", limitationsHandler.CreateUpgradeTrigger)
|
||||
limitations.GET("/upgrade-triggers/:id", limitationsHandler.GetUpgradeTrigger)
|
||||
limitations.PUT("/upgrade-triggers/:id", limitationsHandler.UpdateUpgradeTrigger)
|
||||
limitations.DELETE("/upgrade-triggers/:id", limitationsHandler.DeleteUpgradeTrigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user