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:
Trey t
2025-11-28 11:07:51 -06:00
parent 3cd222c048
commit 5e95dcd015
31 changed files with 2595 additions and 320 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View 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"})
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}