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

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