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:
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user