Make contractor residence optional with visibility rules

- Make residence_id nullable in contractor model
- Add created_by_id field to track contractor creator
- Update access control: personal contractors visible only to creator,
  residence contractors visible to all residence users
- Add database migration for schema changes
- Update admin panel DTOs and handlers for optional residence
- Fix test utilities for new model structure

🤖 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-29 18:42:11 -06:00
parent 9e91e274e8
commit 4e9b31377b
13 changed files with 123 additions and 97 deletions

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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,

View File

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

View File

@@ -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.",

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,