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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,9 @@ func Migrate() error {
|
||||
&models.Contractor{}, // Contractor before Task (Task references Contractor)
|
||||
&models.Task{},
|
||||
&models.TaskCompletion{},
|
||||
&models.TaskCompletionImage{}, // Multiple images per completion
|
||||
&models.Document{},
|
||||
&models.DocumentImage{}, // Multiple images per document
|
||||
|
||||
// Notification tables
|
||||
&models.Notification{},
|
||||
|
||||
@@ -25,6 +25,7 @@ type CreateDocumentRequest struct {
|
||||
SerialNumber string `json:"serial_number" binding:"max=100"`
|
||||
ModelNumber string `json:"model_number" binding:"max=100"`
|
||||
TaskID *uint `json:"task_id"`
|
||||
ImageURLs []string `json:"image_urls"` // Multiple image URLs
|
||||
}
|
||||
|
||||
// UpdateDocumentRequest represents the request to update a document
|
||||
|
||||
@@ -88,5 +88,12 @@ type CreateTaskCompletionRequest struct {
|
||||
CompletedAt *time.Time `json:"completed_at"` // Defaults to now
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
Rating *int `json:"rating"` // 1-5 star rating
|
||||
ImageURLs []string `json:"image_urls"` // Multiple image URLs
|
||||
}
|
||||
|
||||
// CompletionImageInput represents an image to add to a completion
|
||||
type CompletionImageInput struct {
|
||||
ImageURL string `json:"image_url" binding:"required"`
|
||||
Caption string `json:"caption"`
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ type DocumentUserResponse struct {
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
// DocumentImageResponse represents an image in a document
|
||||
type DocumentImageResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Caption string `json:"caption"`
|
||||
}
|
||||
|
||||
// DocumentResponse represents a document in the API response
|
||||
type DocumentResponse struct {
|
||||
ID uint `json:"id"`
|
||||
@@ -36,10 +43,11 @@ type DocumentResponse struct {
|
||||
Vendor string `json:"vendor"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
ModelNumber string `json:"model_number"`
|
||||
TaskID *uint `json:"task_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TaskID *uint `json:"task_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Images []DocumentImageResponse `json:"images"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Note: Pagination removed - list endpoints now return arrays directly
|
||||
@@ -81,6 +89,7 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
|
||||
ModelNumber: d.ModelNumber,
|
||||
TaskID: d.TaskID,
|
||||
IsActive: d.IsActive,
|
||||
Images: make([]DocumentImageResponse, 0),
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
@@ -89,6 +98,15 @@ func NewDocumentResponse(d *models.Document) DocumentResponse {
|
||||
resp.CreatedBy = NewDocumentUserResponse(&d.CreatedBy)
|
||||
}
|
||||
|
||||
// Convert images
|
||||
for _, img := range d.Images {
|
||||
resp.Images = append(resp.Images, DocumentImageResponse{
|
||||
ID: img.ID,
|
||||
ImageURL: img.ImageURL,
|
||||
Caption: img.Caption,
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
@@ -54,16 +54,24 @@ type TaskUserResponse struct {
|
||||
LastName string `json:"last_name"`
|
||||
}
|
||||
|
||||
// TaskCompletionImageResponse represents a completion image
|
||||
type TaskCompletionImageResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Caption string `json:"caption"`
|
||||
}
|
||||
|
||||
// TaskCompletionResponse represents a task completion
|
||||
type TaskCompletionResponse struct {
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
CompletedBy *TaskUserResponse `json:"completed_by,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `json:"id"`
|
||||
TaskID uint `json:"task_id"`
|
||||
CompletedBy *TaskUserResponse `json:"completed_by,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Notes string `json:"notes"`
|
||||
ActualCost *decimal.Decimal `json:"actual_cost"`
|
||||
Rating *int `json:"rating"`
|
||||
Images []TaskCompletionImageResponse `json:"images"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TaskResponse represents a task in the API response
|
||||
@@ -198,12 +206,21 @@ func NewTaskCompletionResponse(c *models.TaskCompletion) TaskCompletionResponse
|
||||
CompletedAt: c.CompletedAt,
|
||||
Notes: c.Notes,
|
||||
ActualCost: c.ActualCost,
|
||||
PhotoURL: c.PhotoURL,
|
||||
Rating: c.Rating,
|
||||
Images: make([]TaskCompletionImageResponse, 0),
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
if c.CompletedBy.ID != 0 {
|
||||
resp.CompletedBy = NewTaskUserResponse(&c.CompletedBy)
|
||||
}
|
||||
// Convert images
|
||||
for _, img := range c.Images {
|
||||
resp.Images = append(resp.Images, TaskCompletionImageResponse{
|
||||
ID: img.ID,
|
||||
ImageURL: img.ImageURL,
|
||||
Caption: img.Caption,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,17 @@ func (h *SubscriptionHandler) GetUpgradeTrigger(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, trigger)
|
||||
}
|
||||
|
||||
// GetAllUpgradeTriggers handles GET /api/subscription/upgrade-triggers/
|
||||
func (h *SubscriptionHandler) GetAllUpgradeTriggers(c *gin.Context) {
|
||||
triggers, err := h.subscriptionService.GetAllUpgradeTriggers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, triggers)
|
||||
}
|
||||
|
||||
// GetFeatureBenefits handles GET /api/subscription/features/
|
||||
func (h *SubscriptionHandler) GetFeatureBenefits(c *gin.Context) {
|
||||
benefits, err := h.subscriptionService.GetFeatureBenefits()
|
||||
|
||||
@@ -419,7 +419,7 @@ func (h *TaskHandler) CreateCompletion(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload image: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.PhotoURL = result.URL
|
||||
req.ImageURLs = append(req.ImageURLs, result.URL)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -37,12 +37,20 @@ type Document struct {
|
||||
MimeType string `gorm:"column:mime_type;size:100" json:"mime_type"`
|
||||
|
||||
// Warranty-specific fields
|
||||
PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"`
|
||||
ExpiryDate *time.Time `gorm:"column:expiry_date;type:date;index" json:"expiry_date"`
|
||||
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
|
||||
Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
|
||||
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"`
|
||||
ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"`
|
||||
PurchaseDate *time.Time `gorm:"column:purchase_date;type:date" json:"purchase_date"`
|
||||
ExpiryDate *time.Time `gorm:"column:expiry_date;type:date;index" json:"expiry_date"`
|
||||
PurchasePrice *decimal.Decimal `gorm:"column:purchase_price;type:decimal(10,2)" json:"purchase_price"`
|
||||
Vendor string `gorm:"column:vendor;size:200" json:"vendor"`
|
||||
SerialNumber string `gorm:"column:serial_number;size:100" json:"serial_number"`
|
||||
ModelNumber string `gorm:"column:model_number;size:100" json:"model_number"`
|
||||
|
||||
// Warranty provider contact fields
|
||||
Provider string `gorm:"column:provider;size:200" json:"provider"`
|
||||
ProviderContact string `gorm:"column:provider_contact;size:200" json:"provider_contact"`
|
||||
ClaimPhone string `gorm:"column:claim_phone;size:50" json:"claim_phone"`
|
||||
ClaimEmail string `gorm:"column:claim_email;size:200" json:"claim_email"`
|
||||
ClaimWebsite string `gorm:"column:claim_website;size:500" json:"claim_website"`
|
||||
Notes string `gorm:"column:notes;type:text" json:"notes"`
|
||||
|
||||
// Associated task (optional)
|
||||
TaskID *uint `gorm:"column:task_id;index" json:"task_id"`
|
||||
@@ -50,6 +58,9 @@ type Document struct {
|
||||
|
||||
// State
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||
|
||||
// Multiple images support
|
||||
Images []DocumentImage `gorm:"foreignKey:DocumentID" json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
@@ -73,3 +84,16 @@ func (d *Document) IsWarrantyExpired() bool {
|
||||
}
|
||||
return time.Now().UTC().After(*d.ExpiryDate)
|
||||
}
|
||||
|
||||
// DocumentImage represents the task_documentimage table
|
||||
type DocumentImage struct {
|
||||
BaseModel
|
||||
DocumentID uint `gorm:"column:document_id;index;not null" json:"document_id"`
|
||||
ImageURL string `gorm:"column:image_url;size:500;not null" json:"image_url"`
|
||||
Caption string `gorm:"column:caption;size:255" json:"caption"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (DocumentImage) TableName() string {
|
||||
return "task_documentimage"
|
||||
}
|
||||
|
||||
@@ -143,7 +143,10 @@ type TaskCompletion struct {
|
||||
CompletedAt time.Time `gorm:"column:completed_at;not null" json:"completed_at"`
|
||||
Notes string `gorm:"column:notes;type:text" json:"notes"`
|
||||
ActualCost *decimal.Decimal `gorm:"column:actual_cost;type:decimal(10,2)" json:"actual_cost"`
|
||||
PhotoURL string `gorm:"column:photo_url;size:500" json:"photo_url"`
|
||||
Rating *int `gorm:"column:rating" json:"rating"` // 1-5 star rating
|
||||
|
||||
// Multiple images support
|
||||
Images []TaskCompletionImage `gorm:"foreignKey:CompletionID" json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
@@ -151,6 +154,19 @@ func (TaskCompletion) TableName() string {
|
||||
return "task_taskcompletion"
|
||||
}
|
||||
|
||||
// TaskCompletionImage represents the task_taskcompletionimage table
|
||||
type TaskCompletionImage struct {
|
||||
BaseModel
|
||||
CompletionID uint `gorm:"column:completion_id;index;not null" json:"completion_id"`
|
||||
ImageURL string `gorm:"column:image_url;size:500;not null" json:"image_url"`
|
||||
Caption string `gorm:"column:caption;size:255" json:"caption"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TaskCompletionImage) TableName() string {
|
||||
return "task_taskcompletionimage"
|
||||
}
|
||||
|
||||
// KanbanColumn represents a column in the kanban board
|
||||
type KanbanColumn struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -23,6 +23,7 @@ func (r *DocumentRepository) FindByID(id uint) (*models.Document, error) {
|
||||
var document models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Task").
|
||||
Preload("Images").
|
||||
Where("id = ? AND is_active = ?", id, true).
|
||||
First(&document).Error
|
||||
if err != nil {
|
||||
@@ -35,6 +36,7 @@ func (r *DocumentRepository) FindByID(id uint) (*models.Document, error) {
|
||||
func (r *DocumentRepository) FindByResidence(residenceID uint) ([]models.Document, error) {
|
||||
var documents []models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Images").
|
||||
Where("residence_id = ? AND is_active = ?", residenceID, true).
|
||||
Order("created_at DESC").
|
||||
Find(&documents).Error
|
||||
@@ -46,6 +48,7 @@ func (r *DocumentRepository) FindByUser(residenceIDs []uint) ([]models.Document,
|
||||
var documents []models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Residence").
|
||||
Preload("Images").
|
||||
Where("residence_id IN ? AND is_active = ?", residenceIDs, true).
|
||||
Order("created_at DESC").
|
||||
Find(&documents).Error
|
||||
@@ -57,6 +60,7 @@ func (r *DocumentRepository) FindWarranties(residenceIDs []uint) ([]models.Docum
|
||||
var documents []models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Residence").
|
||||
Preload("Images").
|
||||
Where("residence_id IN ? AND is_active = ? AND document_type = ?",
|
||||
residenceIDs, true, models.DocumentTypeWarranty).
|
||||
Order("expiry_date ASC NULLS LAST").
|
||||
@@ -72,6 +76,7 @@ func (r *DocumentRepository) FindExpiringWarranties(residenceIDs []uint, days in
|
||||
var documents []models.Document
|
||||
err := r.db.Preload("CreatedBy").
|
||||
Preload("Residence").
|
||||
Preload("Images").
|
||||
Where("residence_id IN ? AND is_active = ? AND document_type = ? AND expiry_date > ? AND expiry_date <= ?",
|
||||
residenceIDs, true, models.DocumentTypeWarranty, now, threshold).
|
||||
Order("expiry_date ASC").
|
||||
@@ -121,5 +126,20 @@ func (r *DocumentRepository) CountByResidence(residenceID uint) (int64, error) {
|
||||
|
||||
// FindByIDIncludingInactive finds a document by ID including inactive ones
|
||||
func (r *DocumentRepository) FindByIDIncludingInactive(id uint, document *models.Document) error {
|
||||
return r.db.Preload("CreatedBy").First(document, id).Error
|
||||
return r.db.Preload("CreatedBy").Preload("Images").First(document, id).Error
|
||||
}
|
||||
|
||||
// CreateDocumentImage creates a new document image
|
||||
func (r *DocumentRepository) CreateDocumentImage(image *models.DocumentImage) error {
|
||||
return r.db.Create(image).Error
|
||||
}
|
||||
|
||||
// DeleteDocumentImage deletes a document image
|
||||
func (r *DocumentRepository) DeleteDocumentImage(id uint) error {
|
||||
return r.db.Delete(&models.DocumentImage{}, id).Error
|
||||
}
|
||||
|
||||
// DeleteDocumentImages deletes all images for a document
|
||||
func (r *DocumentRepository) DeleteDocumentImages(documentID uint) error {
|
||||
return r.db.Where("document_id = ?", documentID).Delete(&models.DocumentImage{}).Error
|
||||
}
|
||||
|
||||
@@ -435,6 +435,7 @@ func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, er
|
||||
var completion models.TaskCompletion
|
||||
err := r.db.Preload("Task").
|
||||
Preload("CompletedBy").
|
||||
Preload("Images").
|
||||
First(&completion, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -446,6 +447,7 @@ func (r *TaskRepository) FindCompletionByID(id uint) (*models.TaskCompletion, er
|
||||
func (r *TaskRepository) FindCompletionsByTask(taskID uint) ([]models.TaskCompletion, error) {
|
||||
var completions []models.TaskCompletion
|
||||
err := r.db.Preload("CompletedBy").
|
||||
Preload("Images").
|
||||
Where("task_id = ?", taskID).
|
||||
Order("completed_at DESC").
|
||||
Find(&completions).Error
|
||||
@@ -457,6 +459,7 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
|
||||
var completions []models.TaskCompletion
|
||||
err := r.db.Preload("Task").
|
||||
Preload("CompletedBy").
|
||||
Preload("Images").
|
||||
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
||||
Where("task_task.residence_id IN ?", residenceIDs).
|
||||
Order("completed_at DESC").
|
||||
@@ -466,9 +469,21 @@ func (r *TaskRepository) FindCompletionsByUser(userID uint, residenceIDs []uint)
|
||||
|
||||
// DeleteCompletion deletes a task completion
|
||||
func (r *TaskRepository) DeleteCompletion(id uint) error {
|
||||
// Delete images first
|
||||
r.db.Where("completion_id = ?", id).Delete(&models.TaskCompletionImage{})
|
||||
return r.db.Delete(&models.TaskCompletion{}, id).Error
|
||||
}
|
||||
|
||||
// CreateCompletionImage creates a new completion image
|
||||
func (r *TaskRepository) CreateCompletionImage(image *models.TaskCompletionImage) error {
|
||||
return r.db.Create(image).Error
|
||||
}
|
||||
|
||||
// DeleteCompletionImage deletes a completion image
|
||||
func (r *TaskRepository) DeleteCompletionImage(id uint) error {
|
||||
return r.db.Delete(&models.TaskCompletionImage{}, id).Error
|
||||
}
|
||||
|
||||
// TaskStatistics represents aggregated task statistics
|
||||
type TaskStatistics struct {
|
||||
TotalTasks int
|
||||
|
||||
@@ -325,6 +325,7 @@ func setupSubscriptionRoutes(api *gin.RouterGroup, subscriptionHandler *handlers
|
||||
{
|
||||
subscription.GET("/", subscriptionHandler.GetSubscription)
|
||||
subscription.GET("/status/", subscriptionHandler.GetSubscriptionStatus)
|
||||
subscription.GET("/upgrade-triggers/", subscriptionHandler.GetAllUpgradeTriggers)
|
||||
subscription.GET("/upgrade-trigger/:key/", subscriptionHandler.GetUpgradeTrigger)
|
||||
subscription.GET("/features/", subscriptionHandler.GetFeatureBenefits)
|
||||
subscription.GET("/promotions/", subscriptionHandler.GetPromotions)
|
||||
|
||||
@@ -142,6 +142,20 @@ func (s *DocumentService) CreateDocument(req *requests.CreateDocumentRequest, us
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create images if provided
|
||||
for _, imageURL := range req.ImageURLs {
|
||||
if imageURL != "" {
|
||||
img := &models.DocumentImage{
|
||||
DocumentID: document.ID,
|
||||
ImageURL: imageURL,
|
||||
}
|
||||
if err := s.documentRepo.CreateDocumentImage(img); err != nil {
|
||||
// Log but don't fail the whole operation
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
document, err = s.documentRepo.FindByID(document.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,26 +68,52 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
|
||||
// Get all tier limits and build a map
|
||||
allLimits, err := s.subscriptionRepo.GetAllTierLimits()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get current usage if limitations are enabled
|
||||
var usage *UsageResponse
|
||||
if settings.EnableLimitations {
|
||||
usage, err = s.getUserUsage(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limitsMap := make(map[string]*TierLimitsClientResponse)
|
||||
for _, l := range allLimits {
|
||||
limitsMap[string(l.Tier)] = NewTierLimitsClientResponse(&l)
|
||||
}
|
||||
|
||||
return &SubscriptionStatusResponse{
|
||||
Subscription: NewSubscriptionResponse(sub),
|
||||
Limits: NewTierLimitsResponse(limits),
|
||||
Usage: usage,
|
||||
// Ensure both free and pro exist with defaults if missing
|
||||
if _, ok := limitsMap["free"]; !ok {
|
||||
defaults := models.GetDefaultFreeLimits()
|
||||
limitsMap["free"] = NewTierLimitsClientResponse(&defaults)
|
||||
}
|
||||
if _, ok := limitsMap["pro"]; !ok {
|
||||
defaults := models.GetDefaultProLimits()
|
||||
limitsMap["pro"] = NewTierLimitsClientResponse(&defaults)
|
||||
}
|
||||
|
||||
// Get current usage
|
||||
usage, err := s.getUserUsage(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build flattened response (KMM expects subscription fields at top level)
|
||||
resp := &SubscriptionStatusResponse{
|
||||
AutoRenew: sub.AutoRenew,
|
||||
Limits: limitsMap,
|
||||
Usage: usage,
|
||||
LimitationsEnabled: settings.EnableLimitations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Format dates if present
|
||||
if sub.SubscribedAt != nil {
|
||||
t := sub.SubscribedAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.SubscribedAt = &t
|
||||
}
|
||||
if sub.ExpiresAt != nil {
|
||||
t := sub.ExpiresAt.Format("2006-01-02T15:04:05Z")
|
||||
resp.ExpiresAt = &t
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getUserUsage calculates current usage for a user
|
||||
@@ -121,10 +147,10 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
|
||||
}
|
||||
|
||||
return &UsageResponse{
|
||||
Properties: propertiesCount,
|
||||
Tasks: tasksCount,
|
||||
Contractors: contractorsCount,
|
||||
Documents: documentsCount,
|
||||
PropertiesCount: propertiesCount,
|
||||
TasksCount: tasksCount,
|
||||
ContractorsCount: contractorsCount,
|
||||
DocumentsCount: documentsCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -162,19 +188,19 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
|
||||
|
||||
switch limitType {
|
||||
case "properties":
|
||||
if limits.PropertiesLimit != nil && usage.Properties >= int64(*limits.PropertiesLimit) {
|
||||
if limits.PropertiesLimit != nil && usage.PropertiesCount >= int64(*limits.PropertiesLimit) {
|
||||
return ErrPropertiesLimitExceeded
|
||||
}
|
||||
case "tasks":
|
||||
if limits.TasksLimit != nil && usage.Tasks >= int64(*limits.TasksLimit) {
|
||||
if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) {
|
||||
return ErrTasksLimitExceeded
|
||||
}
|
||||
case "contractors":
|
||||
if limits.ContractorsLimit != nil && usage.Contractors >= int64(*limits.ContractorsLimit) {
|
||||
if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) {
|
||||
return ErrContractorsLimitExceeded
|
||||
}
|
||||
case "documents":
|
||||
if limits.DocumentsLimit != nil && usage.Documents >= int64(*limits.DocumentsLimit) {
|
||||
if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) {
|
||||
return ErrDocumentsLimitExceeded
|
||||
}
|
||||
}
|
||||
@@ -194,6 +220,21 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
|
||||
return NewUpgradeTriggerResponse(trigger), nil
|
||||
}
|
||||
|
||||
// GetAllUpgradeTriggers gets all active upgrade triggers as a map keyed by trigger_key
|
||||
// KMM client expects Map<String, UpgradeTriggerData>
|
||||
func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) {
|
||||
triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]*UpgradeTriggerDataResponse)
|
||||
for _, t := range triggers {
|
||||
result[t.TriggerKey] = NewUpgradeTriggerDataResponse(&t)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetFeatureBenefits gets all feature benefits
|
||||
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
|
||||
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
|
||||
@@ -331,23 +372,47 @@ func NewTierLimitsResponse(l *models.TierLimits) *TierLimitsResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// UsageResponse represents current usage
|
||||
// UsageResponse represents current usage (KMM client expects _count suffix)
|
||||
type UsageResponse struct {
|
||||
Properties int64 `json:"properties"`
|
||||
Tasks int64 `json:"tasks"`
|
||||
Contractors int64 `json:"contractors"`
|
||||
Documents int64 `json:"documents"`
|
||||
PropertiesCount int64 `json:"properties_count"`
|
||||
TasksCount int64 `json:"tasks_count"`
|
||||
ContractorsCount int64 `json:"contractors_count"`
|
||||
DocumentsCount int64 `json:"documents_count"`
|
||||
}
|
||||
|
||||
// TierLimitsClientResponse represents tier limits for mobile client (simple field names)
|
||||
type TierLimitsClientResponse struct {
|
||||
Properties *int `json:"properties"`
|
||||
Tasks *int `json:"tasks"`
|
||||
Contractors *int `json:"contractors"`
|
||||
Documents *int `json:"documents"`
|
||||
}
|
||||
|
||||
// NewTierLimitsClientResponse creates a TierLimitsClientResponse from a model
|
||||
func NewTierLimitsClientResponse(l *models.TierLimits) *TierLimitsClientResponse {
|
||||
return &TierLimitsClientResponse{
|
||||
Properties: l.PropertiesLimit,
|
||||
Tasks: l.TasksLimit,
|
||||
Contractors: l.ContractorsLimit,
|
||||
Documents: l.DocumentsLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionStatusResponse represents full subscription status
|
||||
// Fields are flattened to match KMM client expectations
|
||||
type SubscriptionStatusResponse struct {
|
||||
Subscription *SubscriptionResponse `json:"subscription"`
|
||||
Limits *TierLimitsResponse `json:"limits"`
|
||||
Usage *UsageResponse `json:"usage,omitempty"`
|
||||
LimitationsEnabled bool `json:"limitations_enabled"`
|
||||
// Flattened subscription fields (KMM expects these at top level)
|
||||
SubscribedAt *string `json:"subscribed_at"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
|
||||
// Other fields
|
||||
Usage *UsageResponse `json:"usage"`
|
||||
Limits map[string]*TierLimitsClientResponse `json:"limits"`
|
||||
LimitationsEnabled bool `json:"limitations_enabled"`
|
||||
}
|
||||
|
||||
// UpgradeTriggerResponse represents an upgrade trigger
|
||||
// UpgradeTriggerResponse represents an upgrade trigger (includes trigger_key)
|
||||
type UpgradeTriggerResponse struct {
|
||||
TriggerKey string `json:"trigger_key"`
|
||||
Title string `json:"title"`
|
||||
@@ -367,6 +432,29 @@ func NewUpgradeTriggerResponse(t *models.UpgradeTrigger) *UpgradeTriggerResponse
|
||||
}
|
||||
}
|
||||
|
||||
// UpgradeTriggerDataResponse represents trigger data for map values (no trigger_key)
|
||||
// Matches KMM UpgradeTriggerData model
|
||||
type UpgradeTriggerDataResponse struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
PromoHTML *string `json:"promo_html"`
|
||||
ButtonText string `json:"button_text"`
|
||||
}
|
||||
|
||||
// NewUpgradeTriggerDataResponse creates an UpgradeTriggerDataResponse from a model
|
||||
func NewUpgradeTriggerDataResponse(t *models.UpgradeTrigger) *UpgradeTriggerDataResponse {
|
||||
var promoHTML *string
|
||||
if t.PromoHTML != "" {
|
||||
promoHTML = &t.PromoHTML
|
||||
}
|
||||
return &UpgradeTriggerDataResponse{
|
||||
Title: t.Title,
|
||||
Message: t.Message,
|
||||
PromoHTML: promoHTML,
|
||||
ButtonText: t.ButtonText,
|
||||
}
|
||||
}
|
||||
|
||||
// FeatureBenefitResponse represents a feature benefit
|
||||
type FeatureBenefitResponse struct {
|
||||
FeatureName string `json:"feature_name"`
|
||||
|
||||
@@ -475,14 +475,27 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
||||
CompletedAt: completedAt,
|
||||
Notes: req.Notes,
|
||||
ActualCost: req.ActualCost,
|
||||
PhotoURL: req.PhotoURL,
|
||||
Rating: req.Rating,
|
||||
}
|
||||
|
||||
if err := s.taskRepo.CreateCompletion(completion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reload completion with user info
|
||||
// Create images if provided
|
||||
for _, imageURL := range req.ImageURLs {
|
||||
if imageURL != "" {
|
||||
img := &models.TaskCompletionImage{
|
||||
CompletionID: completion.ID,
|
||||
ImageURL: imageURL,
|
||||
}
|
||||
if err := s.taskRepo.CreateCompletionImage(img); err != nil {
|
||||
log.Error().Err(err).Uint("completion_id", completion.ID).Msg("Failed to create completion image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload completion with user info and images
|
||||
completion, err = s.taskRepo.FindCompletionByID(completion.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user