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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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