Migrate from Gin to Echo framework and add comprehensive integration tests

Major changes:
- Migrate all handlers from Gin to Echo framework
- Add new apperrors, echohelpers, and validator packages
- Update middleware for Echo compatibility
- Add ArchivedHandler to task categorization chain (archived tasks go to cancelled_tasks column)
- Add 6 new integration tests:
  - RecurringTaskLifecycle: NextDueDate advancement for weekly/monthly tasks
  - MultiUserSharing: Complex sharing with user removal
  - TaskStateTransitions: All state transitions and kanban column changes
  - DateBoundaryEdgeCases: Threshold boundary testing
  - CascadeOperations: Residence deletion cascade effects
  - MultiUserOperations: Shared residence collaboration
- Add single-purpose repository functions for kanban columns (GetOverdueTasks, GetDueSoonTasks, etc.)
- Fix RemoveUser route param mismatch (userId -> user_id)
- Fix determineExpectedColumn helper to correctly prioritize in_progress over overdue

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-16 13:52:08 -06:00
parent c51f1ce34a
commit 6dac34e373
98 changed files with 8209 additions and 4425 deletions

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
@@ -15,12 +16,19 @@ import (
// Subscription-related errors
var (
// Deprecated: Use apperrors.NotFound("error.subscription_not_found") instead
ErrSubscriptionNotFound = errors.New("subscription not found")
// Deprecated: Use apperrors.Forbidden("error.properties_limit_exceeded") instead
ErrPropertiesLimitExceeded = errors.New("properties limit exceeded for your subscription tier")
// Deprecated: Use apperrors.Forbidden("error.tasks_limit_exceeded") instead
ErrTasksLimitExceeded = errors.New("tasks limit exceeded for your subscription tier")
// Deprecated: Use apperrors.Forbidden("error.contractors_limit_exceeded") instead
ErrContractorsLimitExceeded = errors.New("contractors limit exceeded for your subscription tier")
// Deprecated: Use apperrors.Forbidden("error.documents_limit_exceeded") instead
ErrDocumentsLimitExceeded = errors.New("documents limit exceeded for your subscription tier")
// Deprecated: Use apperrors.NotFound("error.upgrade_trigger_not_found") instead
ErrUpgradeTriggerNotFound = errors.New("upgrade trigger not found")
// Deprecated: Use apperrors.NotFound("error.promotion_not_found") instead
ErrPromotionNotFound = errors.New("promotion not found")
)
@@ -93,7 +101,7 @@ func NewSubscriptionService(
func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return NewSubscriptionResponse(sub), nil
}
@@ -102,18 +110,18 @@ func (s *SubscriptionService) GetSubscription(userID uint) (*SubscriptionRespons
func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionStatusResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
settings, err := s.subscriptionRepo.GetSettings()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Get all tier limits and build a map
allLimits, err := s.subscriptionRepo.GetAllTierLimits()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
limitsMap := make(map[string]*TierLimitsClientResponse)
@@ -169,7 +177,7 @@ func (s *SubscriptionService) GetSubscriptionStatus(userID uint) (*SubscriptionS
func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error) {
residences, err := s.residenceRepo.FindOwnedByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
propertiesCount := int64(len(residences))
@@ -178,19 +186,19 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
for _, r := range residences {
tc, err := s.taskRepo.CountByResidence(r.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
tasksCount += tc
cc, err := s.contractorRepo.CountByResidence(r.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
contractorsCount += cc
dc, err := s.documentRepo.CountByResidence(r.ID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
documentsCount += dc
}
@@ -207,7 +215,7 @@ func (s *SubscriptionService) getUserUsage(userID uint) (*UsageResponse, error)
func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
settings, err := s.subscriptionRepo.GetSettings()
if err != nil {
return err
return apperrors.Internal(err)
}
// If limitations are disabled globally, allow everything
@@ -217,7 +225,7 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
if err != nil {
return err
return apperrors.Internal(err)
}
// IsFree users bypass all limitations
@@ -232,7 +240,7 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
limits, err := s.subscriptionRepo.GetTierLimits(sub.Tier)
if err != nil {
return err
return apperrors.Internal(err)
}
usage, err := s.getUserUsage(userID)
@@ -243,19 +251,19 @@ func (s *SubscriptionService) CheckLimit(userID uint, limitType string) error {
switch limitType {
case "properties":
if limits.PropertiesLimit != nil && usage.PropertiesCount >= int64(*limits.PropertiesLimit) {
return ErrPropertiesLimitExceeded
return apperrors.Forbidden("error.properties_limit_exceeded")
}
case "tasks":
if limits.TasksLimit != nil && usage.TasksCount >= int64(*limits.TasksLimit) {
return ErrTasksLimitExceeded
return apperrors.Forbidden("error.tasks_limit_exceeded")
}
case "contractors":
if limits.ContractorsLimit != nil && usage.ContractorsCount >= int64(*limits.ContractorsLimit) {
return ErrContractorsLimitExceeded
return apperrors.Forbidden("error.contractors_limit_exceeded")
}
case "documents":
if limits.DocumentsLimit != nil && usage.DocumentsCount >= int64(*limits.DocumentsLimit) {
return ErrDocumentsLimitExceeded
return apperrors.Forbidden("error.documents_limit_exceeded")
}
}
@@ -267,9 +275,9 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
trigger, err := s.subscriptionRepo.GetUpgradeTrigger(key)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUpgradeTriggerNotFound
return nil, apperrors.NotFound("error.upgrade_trigger_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
return NewUpgradeTriggerResponse(trigger), nil
}
@@ -279,7 +287,7 @@ func (s *SubscriptionService) GetUpgradeTrigger(key string) (*UpgradeTriggerResp
func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTriggerDataResponse, error) {
triggers, err := s.subscriptionRepo.GetAllUpgradeTriggers()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make(map[string]*UpgradeTriggerDataResponse)
@@ -293,7 +301,7 @@ func (s *SubscriptionService) GetAllUpgradeTriggers() (map[string]*UpgradeTrigge
func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, error) {
benefits, err := s.subscriptionRepo.GetFeatureBenefits()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]FeatureBenefitResponse, len(benefits))
@@ -307,12 +315,12 @@ func (s *SubscriptionService) GetFeatureBenefits() ([]FeatureBenefitResponse, er
func (s *SubscriptionService) GetActivePromotions(userID uint) ([]PromotionResponse, error) {
sub, err := s.subscriptionRepo.GetOrCreate(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
promotions, err := s.subscriptionRepo.GetActivePromotions(sub.Tier)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]PromotionResponse, len(promotions))
@@ -331,7 +339,7 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
dataToStore = transactionID
}
if err := s.subscriptionRepo.UpdateReceiptData(userID, dataToStore); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Validate with Apple if client is configured
@@ -375,7 +383,7 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
// Upgrade to Pro with the determined expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "ios"); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
@@ -386,7 +394,7 @@ func (s *SubscriptionService) ProcessApplePurchase(userID uint, receiptData stri
func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken string, productID string) (*SubscriptionResponse, error) {
// Store purchase token first
if err := s.subscriptionRepo.UpdatePurchaseToken(userID, purchaseToken); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Validate the purchase with Google if client is configured
@@ -443,7 +451,7 @@ func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken s
// Upgrade to Pro with the determined expiration
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, "android"); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
@@ -452,7 +460,7 @@ func (s *SubscriptionService) ProcessGooglePurchase(userID uint, purchaseToken s
// CancelSubscription cancels a subscription (downgrades to free at end of period)
func (s *SubscriptionService) CancelSubscription(userID uint) (*SubscriptionResponse, error) {
if err := s.subscriptionRepo.SetAutoRenew(userID, false); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return s.GetSubscription(userID)
}