diff --git a/internal/admin/dto/requests.go b/internal/admin/dto/requests.go index 32b26f8..03a0a21 100644 --- a/internal/admin/dto/requests.go +++ b/internal/admin/dto/requests.go @@ -279,7 +279,7 @@ type CreateTaskRequest struct { // CreateContractorRequest for creating a new contractor type CreateContractorRequest struct { - ResidenceID uint `json:"residence_id" binding:"required"` + ResidenceID *uint `json:"residence_id"` CreatedByID uint `json:"created_by_id" binding:"required"` Name string `json:"name" binding:"required,max=200"` Company string `json:"company" binding:"max=200"` diff --git a/internal/admin/dto/responses.go b/internal/admin/dto/responses.go index 8ff0b18..64666f5 100644 --- a/internal/admin/dto/responses.go +++ b/internal/admin/dto/responses.go @@ -151,8 +151,8 @@ type TaskDetailResponse struct { // ContractorResponse represents a contractor in admin responses type ContractorResponse struct { ID uint `json:"id"` - ResidenceID uint `json:"residence_id"` - ResidenceName string `json:"residence_name"` + ResidenceID *uint `json:"residence_id,omitempty"` + ResidenceName string `json:"residence_name,omitempty"` CreatedByID uint `json:"created_by_id"` CreatedByName string `json:"created_by_name"` Name string `json:"name"` diff --git a/internal/admin/handlers/contractor_handler.go b/internal/admin/handlers/contractor_handler.go index a082b65..e1c51ab 100644 --- a/internal/admin/handlers/contractor_handler.go +++ b/internal/admin/handlers/contractor_handler.go @@ -149,7 +149,7 @@ func (h *AdminContractorHandler) Update(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"}) return } - contractor.ResidenceID = *req.ResidenceID + contractor.ResidenceID = req.ResidenceID } // Verify created_by if changing if req.CreatedByID != nil { @@ -229,11 +229,13 @@ func (h *AdminContractorHandler) Create(c *gin.Context) { return } - // Verify residence exists - var residence models.Residence - if err := h.db.First(&residence, req.ResidenceID).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"}) - return + // Verify residence exists if provided + if req.ResidenceID != nil { + var residence models.Residence + if err := h.db.First(&residence, *req.ResidenceID).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Residence not found"}) + return + } } // Verify created_by user exists @@ -342,7 +344,7 @@ func (h *AdminContractorHandler) toContractorResponse(contractor *models.Contrac UpdatedAt: contractor.UpdatedAt.Format("2006-01-02T15:04:05Z"), } - if contractor.Residence.ID != 0 { + if contractor.Residence != nil && contractor.Residence.ID != 0 { response.ResidenceName = contractor.Residence.Name } if contractor.CreatedBy.ID != 0 { diff --git a/internal/dto/requests/contractor.go b/internal/dto/requests/contractor.go index 4623b3f..9e0213a 100644 --- a/internal/dto/requests/contractor.go +++ b/internal/dto/requests/contractor.go @@ -2,7 +2,7 @@ package requests // CreateContractorRequest represents the request to create a contractor type CreateContractorRequest struct { - ResidenceID uint `json:"residence_id" binding:"required"` + ResidenceID *uint `json:"residence_id"` Name string `json:"name" binding:"required,min=1,max=200"` Company string `json:"company" binding:"max=200"` Phone string `json:"phone" binding:"max=20"` diff --git a/internal/dto/responses/contractor.go b/internal/dto/responses/contractor.go index 0ca1631..2ba156d 100644 --- a/internal/dto/responses/contractor.go +++ b/internal/dto/responses/contractor.go @@ -26,7 +26,7 @@ type ContractorUserResponse struct { // ContractorResponse represents a contractor in the API response type ContractorResponse struct { ID uint `json:"id"` - ResidenceID uint `json:"residence_id"` + ResidenceID *uint `json:"residence_id"` CreatedByID uint `json:"created_by_id"` AddedBy uint `json:"added_by"` // Alias for created_by_id (KMM compatibility) CreatedBy *ContractorUserResponse `json:"created_by,omitempty"` @@ -87,7 +87,7 @@ func NewContractorUserResponse(u *models.User) *ContractorUserResponse { func NewContractorResponse(c *models.Contractor) ContractorResponse { resp := ContractorResponse{ ID: c.ID, - ResidenceID: c.ResidenceID, + ResidenceID: c.ResidenceID, // Already a pointer CreatedByID: c.CreatedByID, AddedBy: c.CreatedByID, // Alias for KMM compatibility Name: c.Name, diff --git a/internal/models/contractor.go b/internal/models/contractor.go index 71362d3..fa66806 100644 --- a/internal/models/contractor.go +++ b/internal/models/contractor.go @@ -17,8 +17,8 @@ func (ContractorSpecialty) TableName() string { // Contractor represents the task_contractor table type Contractor struct { BaseModel - ResidenceID uint `gorm:"column:residence_id;index;not null" json:"residence_id"` - Residence Residence `gorm:"foreignKey:ResidenceID" json:"-"` + ResidenceID *uint `gorm:"column:residence_id;index" json:"residence_id"` + Residence *Residence `gorm:"foreignKey:ResidenceID" json:"-"` CreatedByID uint `gorm:"column:created_by_id;index;not null" json:"created_by_id"` CreatedBy User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"` diff --git a/internal/models/task_test.go b/internal/models/task_test.go index 45577c0..0adb263 100644 --- a/internal/models/task_test.go +++ b/internal/models/task_test.go @@ -205,8 +205,9 @@ func TestTaskCompletion_JSONSerialization(t *testing.T) { } func TestContractor_JSONSerialization(t *testing.T) { + residenceID := uint(1) contractor := Contractor{ - ResidenceID: 1, + ResidenceID: &residenceID, CreatedByID: 1, Name: "Mike's Plumbing", Company: "Mike's Plumbing Co.", diff --git a/internal/repositories/contractor_repo.go b/internal/repositories/contractor_repo.go index 8184398..5a63c2a 100644 --- a/internal/repositories/contractor_repo.go +++ b/internal/repositories/contractor_repo.go @@ -42,14 +42,28 @@ func (r *ContractorRepository) FindByResidence(residenceID uint) ([]models.Contr } // FindByUser finds all contractors accessible to a user -func (r *ContractorRepository) FindByUser(residenceIDs []uint) ([]models.Contractor, error) { +// Returns contractors that either: +// 1. Have no residence (personal contractors) AND were created by the user +// 2. Belong to a residence the user has access to +func (r *ContractorRepository) FindByUser(userID uint, residenceIDs []uint) ([]models.Contractor, error) { var contractors []models.Contractor - err := r.db.Preload("CreatedBy"). + query := r.db.Preload("CreatedBy"). Preload("Specialties"). Preload("Residence"). - Where("residence_id IN ? AND is_active = ?", residenceIDs, true). - Order("is_favorite DESC, name ASC"). - Find(&contractors).Error + Where("is_active = ?", true) + + if len(residenceIDs) > 0 { + // Personal contractors (no residence, created by user) OR residence contractors + query = query.Where( + "(residence_id IS NULL AND created_by_id = ?) OR (residence_id IN ?)", + userID, residenceIDs, + ) + } else { + // Only personal contractors + query = query.Where("residence_id IS NULL AND created_by_id = ?", userID) + } + + err := query.Order("is_favorite DESC, name ASC").Find(&contractors).Error return contractors, err } diff --git a/internal/services/contractor_service.go b/internal/services/contractor_service.go index 0a99ade..d647e07 100644 --- a/internal/services/contractor_service.go +++ b/internal/services/contractor_service.go @@ -41,12 +41,8 @@ func (s *ContractorService) GetContractor(contractorID, userID uint) (*responses return nil, err } - // Check access via residence - hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID) - if err != nil { - return nil, err - } - if !hasAccess { + // Check access + if !s.hasContractorAccess(contractor, userID) { return nil, ErrContractorAccessDenied } @@ -54,6 +50,24 @@ func (s *ContractorService) GetContractor(contractorID, userID uint) (*responses return &resp, nil } +// hasContractorAccess checks if user has access to a contractor +// Access rules: +// - If contractor has no residence: only the creator has access +// - If contractor has a residence: all users with access to that residence +func (s *ContractorService) hasContractorAccess(contractor *models.Contractor, userID uint) bool { + if contractor.ResidenceID == nil { + // Personal contractor - only creator has access + return contractor.CreatedByID == userID + } + + // Residence contractor - check residence access + hasAccess, err := s.residenceRepo.HasAccess(*contractor.ResidenceID, userID) + if err != nil { + return false + } + return hasAccess +} + // ListContractors lists all contractors accessible to a user func (s *ContractorService) ListContractors(userID uint) ([]responses.ContractorResponse, error) { residences, err := s.residenceRepo.FindByUser(userID) @@ -66,11 +80,8 @@ func (s *ContractorService) ListContractors(userID uint) ([]responses.Contractor residenceIDs[i] = r.ID } - if len(residenceIDs) == 0 { - return []responses.ContractorResponse{}, nil - } - - contractors, err := s.contractorRepo.FindByUser(residenceIDs) + // FindByUser now handles both personal and residence contractors + contractors, err := s.contractorRepo.FindByUser(userID, residenceIDs) if err != nil { return nil, err } @@ -80,13 +91,15 @@ func (s *ContractorService) ListContractors(userID uint) ([]responses.Contractor // CreateContractor creates a new contractor func (s *ContractorService) CreateContractor(req *requests.CreateContractorRequest, userID uint) (*responses.ContractorResponse, error) { - // Check residence access - hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID) - if err != nil { - return nil, err - } - if !hasAccess { - return nil, ErrResidenceAccessDenied + // If residence is provided, check access + if req.ResidenceID != nil { + hasAccess, err := s.residenceRepo.HasAccess(*req.ResidenceID, userID) + if err != nil { + return nil, err + } + if !hasAccess { + return nil, ErrResidenceAccessDenied + } } isFavorite := false @@ -124,9 +137,9 @@ func (s *ContractorService) CreateContractor(req *requests.CreateContractorReque } // Reload with relations - contractor, err = s.contractorRepo.FindByID(contractor.ID) - if err != nil { - return nil, err + contractor, reloadErr := s.contractorRepo.FindByID(contractor.ID) + if reloadErr != nil { + return nil, reloadErr } resp := responses.NewContractorResponse(contractor) @@ -144,11 +157,7 @@ func (s *ContractorService) UpdateContractor(contractorID, userID uint, req *req } // Check access - hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID) - if err != nil { - return nil, err - } - if !hasAccess { + if !s.hasContractorAccess(contractor, userID) { return nil, ErrContractorAccessDenied } @@ -222,11 +231,7 @@ func (s *ContractorService) DeleteContractor(contractorID, userID uint) error { } // Check access - hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID) - if err != nil { - return err - } - if !hasAccess { + if !s.hasContractorAccess(contractor, userID) { return ErrContractorAccessDenied } @@ -244,11 +249,7 @@ func (s *ContractorService) ToggleFavorite(contractorID, userID uint) (*response } // Check access - hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID) - if err != nil { - return nil, err - } - if !hasAccess { + if !s.hasContractorAccess(contractor, userID) { return nil, ErrContractorAccessDenied } @@ -278,11 +279,7 @@ func (s *ContractorService) GetContractorTasks(contractorID, userID uint) ([]res } // Check access - hasAccess, err := s.residenceRepo.HasAccess(contractor.ResidenceID, userID) - if err != nil { - return nil, err - } - if !hasAccess { + if !s.hasContractorAccess(contractor, userID) { return nil, ErrContractorAccessDenied } diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 17006b7..81c1103 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -309,7 +309,7 @@ func MockAuthMiddleware(user *models.User) gin.HandlerFunc { // CreateTestContractor creates a test contractor func CreateTestContractor(t *testing.T, db *gorm.DB, residenceID, createdByID uint, name string) *models.Contractor { contractor := &models.Contractor{ - ResidenceID: residenceID, + ResidenceID: &residenceID, CreatedByID: createdByID, Name: name, IsActive: true, diff --git a/migrations/003_contractor_optional_residence.down.sql b/migrations/003_contractor_optional_residence.down.sql new file mode 100644 index 0000000..bd29d95 --- /dev/null +++ b/migrations/003_contractor_optional_residence.down.sql @@ -0,0 +1,4 @@ +-- Revert: Make residence_id required again +-- WARNING: This will fail if there are contractors with NULL residence_id + +ALTER TABLE task_contractor ALTER COLUMN residence_id SET NOT NULL; diff --git a/migrations/003_contractor_optional_residence.up.sql b/migrations/003_contractor_optional_residence.up.sql new file mode 100644 index 0000000..adf8fcf --- /dev/null +++ b/migrations/003_contractor_optional_residence.up.sql @@ -0,0 +1,4 @@ +-- Make residence_id optional for contractors +-- Allows contractors to be personal (no residence) or shared (with residence) + +ALTER TABLE task_contractor ALTER COLUMN residence_id DROP NOT NULL; diff --git a/seeds/001_lookups.sql b/seeds/001_lookups.sql index bfc4197..c37abe6 100644 --- a/seeds/001_lookups.sql +++ b/seeds/001_lookups.sql @@ -2,33 +2,35 @@ -- Run with: ./dev.sh seed -- Residence Types (only has: id, created_at, updated_at, name) +-- Ordered A-Z by name INSERT INTO residence_residencetype (id, created_at, updated_at, name) VALUES - (1, NOW(), NOW(), 'House'), - (2, NOW(), NOW(), 'Apartment'), - (3, NOW(), NOW(), 'Condo'), - (4, NOW(), NOW(), 'Townhouse'), - (5, NOW(), NOW(), 'Duplex'), - (6, NOW(), NOW(), 'Mobile Home'), - (7, NOW(), NOW(), 'Vacation Home'), - (8, NOW(), NOW(), 'Other') + (1, NOW(), NOW(), 'Apartment'), + (2, NOW(), NOW(), 'Condo'), + (3, NOW(), NOW(), 'Duplex'), + (4, NOW(), NOW(), 'House'), + (5, NOW(), NOW(), 'Mobile Home'), + (6, NOW(), NOW(), 'Other'), + (7, NOW(), NOW(), 'Townhouse'), + (8, NOW(), NOW(), 'Vacation Home') ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, updated_at = NOW(); -- Task Categories (has: name, description, icon, color, display_order) +-- Ordered A-Z by name with display_order matching INSERT INTO task_taskcategory (id, created_at, updated_at, name, description, icon, color, display_order) VALUES - (1, NOW(), NOW(), 'Plumbing', 'Plumbing related tasks', 'wrench', '#3498db', 1), - (2, NOW(), NOW(), 'Electrical', 'Electrical work and repairs', 'bolt', '#f1c40f', 2), - (3, NOW(), NOW(), 'HVAC', 'Heating, ventilation, and air conditioning', 'thermometer', '#e74c3c', 3), - (4, NOW(), NOW(), 'Appliances', 'Appliance maintenance and repair', 'cog', '#9b59b6', 4), - (5, NOW(), NOW(), 'Exterior', 'Exterior maintenance and landscaping', 'tree', '#27ae60', 5), - (6, NOW(), NOW(), 'Interior', 'Interior maintenance and repairs', 'home', '#e67e22', 6), - (7, NOW(), NOW(), 'Safety', 'Safety and security tasks', 'shield', '#c0392b', 7), - (8, NOW(), NOW(), 'Cleaning', 'Cleaning and sanitation', 'broom', '#1abc9c', 8), - (9, NOW(), NOW(), 'Pest Control', 'Pest prevention and control', 'bug', '#8e44ad', 9), - (10, NOW(), NOW(), 'General', 'General maintenance tasks', 'tools', '#7f8c8d', 99) + (1, NOW(), NOW(), 'Appliances', 'Appliance maintenance and repair', 'cog', '#9b59b6', 1), + (2, NOW(), NOW(), 'Cleaning', 'Cleaning and sanitation', 'broom', '#1abc9c', 2), + (3, NOW(), NOW(), 'Electrical', 'Electrical work and repairs', 'bolt', '#f1c40f', 3), + (4, NOW(), NOW(), 'Exterior', 'Exterior maintenance and landscaping', 'tree', '#27ae60', 4), + (5, NOW(), NOW(), 'General', 'General maintenance tasks', 'tools', '#7f8c8d', 5), + (6, NOW(), NOW(), 'HVAC', 'Heating, ventilation, and air conditioning', 'thermometer', '#e74c3c', 6), + (7, NOW(), NOW(), 'Interior', 'Interior maintenance and repairs', 'home', '#e67e22', 7), + (8, NOW(), NOW(), 'Pest Control', 'Pest prevention and control', 'bug', '#8e44ad', 8), + (9, NOW(), NOW(), 'Plumbing', 'Plumbing related tasks', 'wrench', '#3498db', 9), + (10, NOW(), NOW(), 'Safety', 'Safety and security tasks', 'shield', '#c0392b', 10) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, @@ -83,25 +85,27 @@ ON CONFLICT (id) DO UPDATE SET display_order = EXCLUDED.display_order, updated_at = NOW(); --- Contractor Specialties (check actual columns) -INSERT INTO task_contractorspecialty (id, created_at, updated_at, name) +-- Contractor Specialties (has: name, description, icon, display_order) +-- Ordered A-Z by name with display_order matching +INSERT INTO task_contractorspecialty (id, created_at, updated_at, name, display_order) VALUES - (1, NOW(), NOW(), 'Plumber'), - (2, NOW(), NOW(), 'Electrician'), - (3, NOW(), NOW(), 'HVAC Technician'), - (4, NOW(), NOW(), 'Handyman'), - (5, NOW(), NOW(), 'Landscaper'), - (6, NOW(), NOW(), 'Painter'), - (7, NOW(), NOW(), 'Roofer'), - (8, NOW(), NOW(), 'Carpenter'), - (9, NOW(), NOW(), 'Appliance Repair'), - (10, NOW(), NOW(), 'Pest Control'), - (11, NOW(), NOW(), 'Cleaner'), - (12, NOW(), NOW(), 'Pool Service'), - (13, NOW(), NOW(), 'Locksmith'), - (14, NOW(), NOW(), 'General Contractor') + (1, NOW(), NOW(), 'Appliance Repair', 1), + (2, NOW(), NOW(), 'Carpenter', 2), + (3, NOW(), NOW(), 'Cleaner', 3), + (4, NOW(), NOW(), 'Electrician', 4), + (5, NOW(), NOW(), 'General Contractor', 5), + (6, NOW(), NOW(), 'Handyman', 6), + (7, NOW(), NOW(), 'HVAC Technician', 7), + (8, NOW(), NOW(), 'Landscaper', 8), + (9, NOW(), NOW(), 'Locksmith', 9), + (10, NOW(), NOW(), 'Painter', 10), + (11, NOW(), NOW(), 'Pest Control', 11), + (12, NOW(), NOW(), 'Plumber', 12), + (13, NOW(), NOW(), 'Pool Service', 13), + (14, NOW(), NOW(), 'Roofer', 14) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, + display_order = EXCLUDED.display_order, updated_at = NOW(); -- Subscription Settings (singleton)