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:
@@ -6,6 +6,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/dto/requests"
|
||||
"github.com/treytartt/casera-api/internal/dto/responses"
|
||||
@@ -14,15 +15,17 @@ import (
|
||||
"github.com/treytartt/casera-api/internal/task/predicates"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
// Common errors (deprecated - kept for reference, now using apperrors package)
|
||||
// Most errors have been migrated to apperrors, but some are still used by other handlers
|
||||
// TODO: Migrate handlers to use apperrors instead of these constants
|
||||
var (
|
||||
ErrResidenceNotFound = errors.New("residence not found")
|
||||
ErrResidenceAccessDenied = errors.New("you do not have access to this residence")
|
||||
ErrNotResidenceOwner = errors.New("only the residence owner can perform this action")
|
||||
ErrCannotRemoveOwner = errors.New("cannot remove the owner from the residence")
|
||||
ErrUserAlreadyMember = errors.New("user is already a member of this residence")
|
||||
ErrShareCodeInvalid = errors.New("invalid or expired share code")
|
||||
ErrShareCodeExpired = errors.New("share code has expired")
|
||||
ErrResidenceNotFound = errors.New("residence not found")
|
||||
ErrResidenceAccessDenied = errors.New("you do not have access to this residence")
|
||||
ErrNotResidenceOwner = errors.New("only the residence owner can perform this action")
|
||||
ErrCannotRemoveOwner = errors.New("cannot remove the owner from the residence")
|
||||
ErrUserAlreadyMember = errors.New("user is already a member of this residence")
|
||||
ErrShareCodeInvalid = errors.New("invalid or expired share code")
|
||||
ErrShareCodeExpired = errors.New("share code has expired")
|
||||
ErrPropertiesLimitReached = errors.New("you have reached the maximum number of properties for your subscription tier")
|
||||
)
|
||||
|
||||
@@ -53,18 +56,18 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.Re
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
return nil, apperrors.Forbidden("error.residence_access_denied")
|
||||
}
|
||||
|
||||
residence, err := s.residenceRepo.FindByID(residenceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrResidenceNotFound
|
||||
return nil, apperrors.NotFound("error.residence_not_found")
|
||||
}
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
resp := responses.NewResidenceResponse(residence)
|
||||
@@ -75,7 +78,7 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.Re
|
||||
func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return responses.NewResidenceListResponse(residences), nil
|
||||
@@ -84,38 +87,31 @@ func (s *ResidenceService) ListResidences(userID uint) ([]responses.ResidenceRes
|
||||
// GetMyResidences returns residences with additional details (tasks, completions, etc.)
|
||||
// This is the "my-residences" endpoint that returns richer data.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
||||
//
|
||||
// NOTE: Summary statistics (TotalTasks, TotalOverdue, etc.) are now calculated client-side
|
||||
// from kanban data for performance. Only TotalResidences and per-residence OverdueCount
|
||||
// are returned from the server.
|
||||
func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*responses.MyResidencesResponse, error) {
|
||||
residences, err := s.residenceRepo.FindByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
residenceResponses := responses.NewResidenceListResponse(residences)
|
||||
|
||||
// Build summary with real task statistics
|
||||
// Summary statistics (TotalTasks, TotalOverdue, etc.) are calculated client-side
|
||||
// from kanban data. We only populate TotalResidences here.
|
||||
summary := responses.TotalSummary{
|
||||
TotalResidences: len(residences),
|
||||
}
|
||||
|
||||
// Get task statistics if task repository is available
|
||||
// Get per-residence overdue counts for residence card badges
|
||||
if s.taskRepo != nil && len(residences) > 0 {
|
||||
// Collect residence IDs
|
||||
residenceIDs := make([]uint, len(residences))
|
||||
for i, r := range residences {
|
||||
residenceIDs[i] = r.ID
|
||||
}
|
||||
|
||||
// Get aggregated statistics using user's timezone-aware time
|
||||
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
|
||||
if err == nil && stats != nil {
|
||||
summary.TotalTasks = stats.TotalTasks
|
||||
summary.TotalPending = stats.TotalPending
|
||||
summary.TotalOverdue = stats.TotalOverdue
|
||||
summary.TasksDueNextWeek = stats.TasksDueNextWeek
|
||||
summary.TasksDueNextMonth = stats.TasksDueNextMonth
|
||||
}
|
||||
|
||||
// Get per-residence overdue counts using user's timezone-aware time
|
||||
overdueCounts, err := s.taskRepo.GetOverdueCountByResidence(residenceIDs, now)
|
||||
if err == nil && overdueCounts != nil {
|
||||
for i := range residenceResponses {
|
||||
@@ -134,32 +130,22 @@ func (s *ResidenceService) GetMyResidences(userID uint, now time.Time) (*respons
|
||||
|
||||
// GetSummary returns just the task summary statistics for a user's residences.
|
||||
// This is a lightweight endpoint for refreshing summary counts without full residence data.
|
||||
// The `now` parameter should be the start of day in the user's timezone for accurate overdue detection.
|
||||
//
|
||||
// DEPRECATED: Summary statistics are now calculated client-side from kanban data.
|
||||
// This endpoint only returns TotalResidences; other fields will be zero.
|
||||
// Clients should use calculateSummaryFromKanban() instead.
|
||||
func (s *ResidenceService) GetSummary(userID uint, now time.Time) (*responses.TotalSummary, error) {
|
||||
// Get residence IDs (lightweight - no preloads)
|
||||
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
summary := &responses.TotalSummary{
|
||||
// Summary statistics are calculated client-side from kanban data.
|
||||
// We only return TotalResidences here.
|
||||
return &responses.TotalSummary{
|
||||
TotalResidences: len(residenceIDs),
|
||||
}
|
||||
|
||||
// Get task statistics if task repository is available
|
||||
if s.taskRepo != nil && len(residenceIDs) > 0 {
|
||||
// Get aggregated statistics using user's timezone-aware time
|
||||
stats, err := s.taskRepo.GetTaskStatistics(residenceIDs, now)
|
||||
if err == nil && stats != nil {
|
||||
summary.TotalTasks = stats.TotalTasks
|
||||
summary.TotalPending = stats.TotalPending
|
||||
summary.TotalOverdue = stats.TotalOverdue
|
||||
summary.TasksDueNextWeek = stats.TasksDueNextWeek
|
||||
summary.TasksDueNextMonth = stats.TasksDueNextMonth
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getSummaryForUser returns an empty summary placeholder.
|
||||
@@ -215,13 +201,13 @@ func (s *ResidenceService) CreateResidence(req *requests.CreateResidenceRequest,
|
||||
}
|
||||
|
||||
if err := s.residenceRepo.Create(residence); err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
residence, err := s.residenceRepo.FindByID(residence.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get updated summary
|
||||
@@ -238,18 +224,18 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques
|
||||
// Check ownership
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, ErrNotResidenceOwner
|
||||
return nil, apperrors.Forbidden("error.not_residence_owner")
|
||||
}
|
||||
|
||||
residence, err := s.residenceRepo.FindByID(residenceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrResidenceNotFound
|
||||
return nil, apperrors.NotFound("error.residence_not_found")
|
||||
}
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Apply updates (only non-nil fields)
|
||||
@@ -306,13 +292,13 @@ func (s *ResidenceService) UpdateResidence(residenceID, userID uint, req *reques
|
||||
}
|
||||
|
||||
if err := s.residenceRepo.Update(residence); err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
residence, err = s.residenceRepo.FindByID(residence.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get updated summary
|
||||
@@ -329,14 +315,14 @@ func (s *ResidenceService) DeleteResidence(residenceID, userID uint) (*responses
|
||||
// Check ownership
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, ErrNotResidenceOwner
|
||||
return nil, apperrors.Forbidden("error.not_residence_owner")
|
||||
}
|
||||
|
||||
if err := s.residenceRepo.Delete(residenceID); err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get updated summary
|
||||
@@ -353,10 +339,10 @@ func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresIn
|
||||
// Check ownership
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, ErrNotResidenceOwner
|
||||
return nil, apperrors.Forbidden("error.not_residence_owner")
|
||||
}
|
||||
|
||||
// Default to 24 hours if not specified
|
||||
@@ -366,7 +352,7 @@ func (s *ResidenceService) GenerateShareCode(residenceID, userID uint, expiresIn
|
||||
|
||||
shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return &responses.GenerateShareCodeResponse{
|
||||
@@ -380,22 +366,22 @@ func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expire
|
||||
// Check ownership (only owners can share residences)
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if !isOwner {
|
||||
return nil, ErrNotResidenceOwner
|
||||
return nil, apperrors.Forbidden("error.not_residence_owner")
|
||||
}
|
||||
|
||||
// Get residence details for the package
|
||||
residence, err := s.residenceRepo.FindByID(residenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get the user who's sharing
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Default to 24 hours if not specified
|
||||
@@ -406,7 +392,7 @@ func (s *ResidenceService) GenerateSharePackage(residenceID, userID uint, expire
|
||||
// Generate the share code
|
||||
shareCode, err := s.residenceRepo.CreateShareCode(residenceID, userID, time.Duration(expiresInHours)*time.Hour)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return &responses.SharePackageResponse{
|
||||
@@ -423,23 +409,23 @@ func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.Jo
|
||||
shareCode, err := s.residenceRepo.FindShareCodeByCode(code)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrShareCodeInvalid
|
||||
return nil, apperrors.NotFound("error.share_code_invalid")
|
||||
}
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
hasAccess, err := s.residenceRepo.HasAccess(shareCode.ResidenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if hasAccess {
|
||||
return nil, ErrUserAlreadyMember
|
||||
return nil, apperrors.Conflict("error.user_already_member")
|
||||
}
|
||||
|
||||
// Add user to residence
|
||||
if err := s.residenceRepo.AddUser(shareCode.ResidenceID, userID); err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Mark share code as used (one-time use)
|
||||
@@ -451,7 +437,7 @@ func (s *ResidenceService) JoinWithCode(code string, userID uint) (*responses.Jo
|
||||
// Get the residence with full details
|
||||
residence, err := s.residenceRepo.FindByID(shareCode.ResidenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get updated summary for the user
|
||||
@@ -469,15 +455,15 @@ func (s *ResidenceService) GetResidenceUsers(residenceID, userID uint) ([]respon
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
return nil, apperrors.Forbidden("error.residence_access_denied")
|
||||
}
|
||||
|
||||
users, err := s.residenceRepo.GetResidenceUsers(residenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
result := make([]responses.ResidenceUserResponse, len(users))
|
||||
@@ -493,39 +479,43 @@ func (s *ResidenceService) RemoveUser(residenceID, userIDToRemove, requestingUse
|
||||
// Check ownership
|
||||
isOwner, err := s.residenceRepo.IsOwner(residenceID, requestingUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
if !isOwner {
|
||||
return ErrNotResidenceOwner
|
||||
return apperrors.Forbidden("error.not_residence_owner")
|
||||
}
|
||||
|
||||
// Cannot remove the owner
|
||||
if userIDToRemove == requestingUserID {
|
||||
return ErrCannotRemoveOwner
|
||||
return apperrors.BadRequest("error.cannot_remove_owner")
|
||||
}
|
||||
|
||||
// Check if the residence exists
|
||||
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrResidenceNotFound
|
||||
return apperrors.NotFound("error.residence_not_found")
|
||||
}
|
||||
return err
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Cannot remove the owner
|
||||
if userIDToRemove == residence.OwnerID {
|
||||
return ErrCannotRemoveOwner
|
||||
return apperrors.BadRequest("error.cannot_remove_owner")
|
||||
}
|
||||
|
||||
return s.residenceRepo.RemoveUser(residenceID, userIDToRemove)
|
||||
if err := s.residenceRepo.RemoveUser(residenceID, userIDToRemove); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetResidenceTypes returns all residence types
|
||||
func (s *ResidenceService) GetResidenceTypes() ([]responses.ResidenceTypeResponse, error) {
|
||||
types, err := s.residenceRepo.GetAllResidenceTypes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
result := make([]responses.ResidenceTypeResponse, len(types))
|
||||
@@ -567,22 +557,25 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrResidenceAccessDenied
|
||||
return nil, apperrors.Forbidden("error.residence_access_denied")
|
||||
}
|
||||
|
||||
// Get residence details
|
||||
residence, err := s.residenceRepo.FindByIDSimple(residenceID)
|
||||
if err != nil {
|
||||
return nil, ErrResidenceNotFound
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, apperrors.NotFound("error.residence_not_found")
|
||||
}
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Get all tasks for the residence
|
||||
tasks, err := s.residenceRepo.GetTasksForReport(residenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
Reference in New Issue
Block a user