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

@@ -5,17 +5,18 @@ import (
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/apperrors"
"github.com/treytartt/casera-api/internal/dto/requests"
"github.com/treytartt/casera-api/internal/dto/responses"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/repositories"
)
// Contractor-related errors
var (
ErrContractorNotFound = errors.New("contractor not found")
ErrContractorAccessDenied = errors.New("you do not have access to this contractor")
)
// Deprecated: Use apperrors.NotFound("error.contractor_not_found") instead
// var (
// ErrContractorNotFound = errors.New("contractor not found")
// ErrContractorAccessDenied = errors.New("you do not have access to this contractor")
// )
// ContractorService handles contractor business logic
type ContractorService struct {
@@ -36,14 +37,14 @@ func (s *ContractorService) GetContractor(contractorID, userID uint) (*responses
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrContractorNotFound
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return nil, ErrContractorAccessDenied
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
resp := responses.NewContractorResponse(contractor)
@@ -73,13 +74,13 @@ func (s *ContractorService) ListContractors(userID uint) ([]responses.Contractor
// Get residence IDs (lightweight - no preloads)
residenceIDs, err := s.residenceRepo.FindResidenceIDsByUser(userID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// FindByUser now handles both personal and residence contractors
contractors, err := s.contractorRepo.FindByUser(userID, residenceIDs)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewContractorListResponse(contractors), nil
@@ -91,10 +92,10 @@ func (s *ContractorService) CreateContractor(req *requests.CreateContractorReque
if req.ResidenceID != nil {
hasAccess, err := s.residenceRepo.HasAccess(*req.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")
}
}
@@ -122,20 +123,20 @@ func (s *ContractorService) CreateContractor(req *requests.CreateContractorReque
}
if err := s.contractorRepo.Create(contractor); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Set specialties if provided
if len(req.SpecialtyIDs) > 0 {
if err := s.contractorRepo.SetSpecialties(contractor.ID, req.SpecialtyIDs); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
}
// Reload with relations
contractor, reloadErr := s.contractorRepo.FindByID(contractor.ID)
if reloadErr != nil {
return nil, reloadErr
return nil, apperrors.Internal(reloadErr)
}
resp := responses.NewContractorResponse(contractor)
@@ -147,14 +148,14 @@ func (s *ContractorService) UpdateContractor(contractorID, userID uint, req *req
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrContractorNotFound
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return nil, ErrContractorAccessDenied
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
// Apply updates
@@ -199,20 +200,20 @@ func (s *ContractorService) UpdateContractor(contractorID, userID uint, req *req
contractor.ResidenceID = req.ResidenceID
if err := s.contractorRepo.Update(contractor); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Update specialties if provided
if req.SpecialtyIDs != nil {
if err := s.contractorRepo.SetSpecialties(contractorID, req.SpecialtyIDs); err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
}
// Reload
contractor, err = s.contractorRepo.FindByID(contractorID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewContractorResponse(contractor)
@@ -224,17 +225,21 @@ func (s *ContractorService) DeleteContractor(contractorID, userID uint) error {
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrContractorNotFound
return apperrors.NotFound("error.contractor_not_found")
}
return err
return apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return ErrContractorAccessDenied
return apperrors.Forbidden("error.contractor_access_denied")
}
return s.contractorRepo.Delete(contractorID)
if err := s.contractorRepo.Delete(contractorID); err != nil {
return apperrors.Internal(err)
}
return nil
}
// ToggleFavorite toggles the favorite status of a contractor and returns the updated contractor
@@ -242,25 +247,25 @@ func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*response
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrContractorNotFound
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return nil, ErrContractorAccessDenied
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
_, err = s.contractorRepo.ToggleFavorite(contractorID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
// Re-fetch the contractor to get the updated state with all relations
contractor, err = s.contractorRepo.FindByID(contractorID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
resp := responses.NewContractorResponse(contractor)
@@ -272,19 +277,19 @@ func (s *ContractorService) GetContractorTasks(contractorID, userID uint) ([]res
contractor, err := s.contractorRepo.FindByID(contractorID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrContractorNotFound
return nil, apperrors.NotFound("error.contractor_not_found")
}
return nil, err
return nil, apperrors.Internal(err)
}
// Check access
if !s.hasContractorAccess(contractor, userID) {
return nil, ErrContractorAccessDenied
return nil, apperrors.Forbidden("error.contractor_access_denied")
}
tasks, err := s.contractorRepo.GetTasksForContractor(contractorID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewTaskListResponse(tasks), nil
@@ -295,15 +300,15 @@ func (s *ContractorService) ListContractorsByResidence(residenceID, userID uint)
// Check user has access to the residence
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")
}
contractors, err := s.contractorRepo.FindByResidence(residenceID)
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
return responses.NewContractorListResponse(contractors), nil
@@ -313,7 +318,7 @@ func (s *ContractorService) ListContractorsByResidence(residenceID, userID uint)
func (s *ContractorService) GetSpecialties() ([]responses.ContractorSpecialtyResponse, error) {
specialties, err := s.contractorRepo.GetAllSpecialties()
if err != nil {
return nil, err
return nil, apperrors.Internal(err)
}
result := make([]responses.ContractorSpecialtyResponse, len(specialties))