diff --git a/README.md b/README.md index 161f0be..9fc430e 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# Tenantly - Property Rental Management System +# 🏠 Tenantly - Property Rental Management System A comprehensive tenant management system designed for the Bangladesh market, focusing on property rental management. -## Overview +## 📋 Overview Tenantly is a robust, multi-service platform built to streamline property management. It handles everything from tenant onboarding and lease management to automated billing and notifications. - **Backend API**: High-performance Go (Gin) service for core logic. - **Frontend**: Modern Angular application with a responsive dashboard. -- **Notification Service**: Background worker built with .NET 8 for SMS/Email alerts. +- **Notification Service**: Background worker built with .NET for SMS/Email alerts. -## Quick Start +## 🚀 Quick Start The fastest way to get started is using Docker Compose. -### Prerequisites +### 🛠️ Prerequisites - [Docker Desktop](https://www.docker.com/products/docker-desktop/) - [Docker Compose](https://docs.docker.com/compose/install/) -### Setup +### ⚙️ Setup 1. **Clone the repository** 2. **Setup environment variables**: @@ -36,7 +36,7 @@ The applications will be available at: - **Backend API**: `http://localhost:8080/api/v1` - **PostgreSQL**: `localhost:5432` -## Project Structure +## 📂 Project Structure For detailed documentation, please refer to the specific component directories: @@ -46,15 +46,15 @@ For detailed documentation, please refer to the specific component directories: | **Notification Service** | .NET Background Service | [backend/notification-service](./src/backend/notification-service/README.md) | | **Frontend** | Angular Application | [frontend](./src/frontend/README.md) | -## Features +## ✨ Features -- **Shop Management**: Track properties, units, and occupancy. +- **Property Management**: Track properties, buildings, units, and occupancy. - **Tenant Management**: Profiles, contact info, and history. - **Lease Processing**: Terms, deposits, and automated renewals. - **Payments**: Multi-channel payment tracking and invoicing. - **Automated Alerts**: SMS and Email reminders for rent and renewals. - **Localization**: Native support for Bengali and BDT (৳). -- **CI/CD**: Automated linting and formatting via GitHub Actions. +- **CI/CD**: Automated linting, formatting, and deployment via GitHub Actions. ## 🤝 Contributing diff --git a/src/backend/api/internal/handlers/property_handler.go b/src/backend/api/internal/handlers/property_handler.go index 4d61d4c..1d1d645 100644 --- a/src/backend/api/internal/handlers/property_handler.go +++ b/src/backend/api/internal/handlers/property_handler.go @@ -50,10 +50,7 @@ func (h *PropertyHandler) CreateProperty(c *gin.Context) { return } - c.JSON(http.StatusCreated, gin.H{ - "message": "Property created successfully", - "property": property, - }) + c.JSON(http.StatusCreated, property) } // GetProperties retrieves properties with filtering and pagination @@ -218,10 +215,7 @@ func (h *PropertyHandler) UpdateProperty(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ - "message": "Property updated successfully", - "property": property, - }) + c.JSON(http.StatusOK, property) } // DeleteProperty soft deletes a property diff --git a/src/backend/api/internal/models/columns/building_columns.go b/src/backend/api/internal/models/columns/building_columns.go new file mode 100644 index 0000000..1e1d9e1 --- /dev/null +++ b/src/backend/api/internal/models/columns/building_columns.go @@ -0,0 +1,55 @@ +package columns + +// Building table and column names +const ( + BuildingTable = "buildings" + BuildingID = "id" + BuildingPropertyID = "property_id" + BuildingName = "building_name" + BuildingCode = "building_code" + BuildingType = "building_type" + BuildingTotalFloors = "total_floors" + BuildingHasElevator = "has_elevator" + BuildingConstructionYear = "construction_year" + BuildingMetadata = "metadata" + BuildingActiveStatus = "active_status" + BuildingCreatedAt = "created_at" + BuildingUpdatedAt = "updated_at" +) + +// BuildingAllColumns returns a comma-separated list of all building columns +// for use in SELECT statements +func BuildingAllColumns() string { + return BuildingID + ", " + + BuildingPropertyID + ", " + + BuildingName + ", " + + BuildingCode + ", " + + BuildingType + ", " + + BuildingTotalFloors + ", " + + BuildingHasElevator + ", " + + BuildingConstructionYear + ", " + + BuildingMetadata + ", " + + BuildingActiveStatus + ", " + + BuildingCreatedAt + ", " + + BuildingUpdatedAt +} + +// BuildingSelectWithAlias returns all building columns with a table alias +// Example: BuildingSelectWithAlias("b") returns "b.id, b.property_id, ..." +func BuildingSelectWithAlias(alias string) string { + if alias == "" { + return BuildingAllColumns() + } + return alias + "." + BuildingID + ", " + + alias + "." + BuildingPropertyID + ", " + + alias + "." + BuildingName + ", " + + alias + "." + BuildingCode + ", " + + alias + "." + BuildingType + ", " + + alias + "." + BuildingTotalFloors + ", " + + alias + "." + BuildingHasElevator + ", " + + alias + "." + BuildingConstructionYear + ", " + + alias + "." + BuildingMetadata + ", " + + alias + "." + BuildingActiveStatus + ", " + + alias + "." + BuildingCreatedAt + ", " + + alias + "." + BuildingUpdatedAt +} diff --git a/src/backend/api/internal/models/columns/property_columns.go b/src/backend/api/internal/models/columns/property_columns.go new file mode 100644 index 0000000..5cc557f --- /dev/null +++ b/src/backend/api/internal/models/columns/property_columns.go @@ -0,0 +1,55 @@ +package columns + +// Property table and column names +const ( + PropertyTable = "properties" + PropertyID = "id" + PropertyName = "property_name" + PropertyCode = "property_code" + PropertyAddress = "address" + PropertyCity = "city" + PropertyPostalCode = "postal_code" + PropertyType = "property_type" + PropertyTotalBuildings = "total_buildings" + PropertyMetadata = "metadata" + PropertyActive = "active" + PropertyCreatedAt = "created_at" + PropertyUpdatedAt = "updated_at" +) + +// PropertyAllColumns returns a comma-separated list of all property columns +// for use in SELECT statements +func PropertyAllColumns() string { + return PropertyID + ", " + + PropertyName + ", " + + PropertyCode + ", " + + PropertyAddress + ", " + + PropertyCity + ", " + + PropertyPostalCode + ", " + + PropertyType + ", " + + PropertyTotalBuildings + ", " + + PropertyMetadata + ", " + + PropertyActive + ", " + + PropertyCreatedAt + ", " + + PropertyUpdatedAt +} + +// PropertySelectWithAlias returns all property columns with a table alias +// Example: PropertySelectWithAlias("p") returns "p.id, p.property_name, ..." +func PropertySelectWithAlias(alias string) string { + if alias == "" { + return PropertyAllColumns() + } + return alias + "." + PropertyID + ", " + + alias + "." + PropertyName + ", " + + alias + "." + PropertyCode + ", " + + alias + "." + PropertyAddress + ", " + + alias + "." + PropertyCity + ", " + + alias + "." + PropertyPostalCode + ", " + + alias + "." + PropertyType + ", " + + alias + "." + PropertyTotalBuildings + ", " + + alias + "." + PropertyMetadata + ", " + + alias + "." + PropertyActive + ", " + + alias + "." + PropertyCreatedAt + ", " + + alias + "." + PropertyUpdatedAt +} diff --git a/src/backend/api/internal/models/columns/unit_columns.go b/src/backend/api/internal/models/columns/unit_columns.go new file mode 100644 index 0000000..e4f4d37 --- /dev/null +++ b/src/backend/api/internal/models/columns/unit_columns.go @@ -0,0 +1,58 @@ +package columns + +// Unit table and column names +const ( + UnitTable = "units" + UnitID = "id" + UnitBuildingID = "building_id" + UnitPropertyID = "property_id" + UnitNumber = "unit_number" + UnitName = "unit_name" + UnitFloor = "floor" + UnitSection = "section" + UnitType = "unit_type" + UnitMonthlyRent = "monthly_rent" + UnitMetadata = "metadata" + UnitActive = "active" + UnitCreatedAt = "created_at" + UnitUpdatedAt = "updated_at" +) + +// UnitAllColumns returns a comma-separated list of all unit columns +// for use in SELECT statements +func UnitAllColumns() string { + return UnitID + ", " + + UnitBuildingID + ", " + + UnitPropertyID + ", " + + UnitNumber + ", " + + UnitName + ", " + + UnitFloor + ", " + + UnitSection + ", " + + UnitType + ", " + + UnitMonthlyRent + ", " + + UnitMetadata + ", " + + UnitActive + ", " + + UnitCreatedAt + ", " + + UnitUpdatedAt +} + +// UnitSelectWithAlias returns all unit columns with a table alias +// Example: UnitSelectWithAlias("u") returns "u.id, u.building_id, ..." +func UnitSelectWithAlias(alias string) string { + if alias == "" { + return UnitAllColumns() + } + return alias + "." + UnitID + ", " + + alias + "." + UnitBuildingID + ", " + + alias + "." + UnitPropertyID + ", " + + alias + "." + UnitNumber + ", " + + alias + "." + UnitName + ", " + + alias + "." + UnitFloor + ", " + + alias + "." + UnitSection + ", " + + alias + "." + UnitType + ", " + + alias + "." + UnitMonthlyRent + ", " + + alias + "." + UnitMetadata + ", " + + alias + "." + UnitActive + ", " + + alias + "." + UnitCreatedAt + ", " + + alias + "." + UnitUpdatedAt +} diff --git a/src/backend/api/internal/repositories/building_repository.go b/src/backend/api/internal/repositories/building_repository.go index e604e98..755b3fb 100644 --- a/src/backend/api/internal/repositories/building_repository.go +++ b/src/backend/api/internal/repositories/building_repository.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/ysnarafat/tenantly/internal/models" + "github.com/ysnarafat/tenantly/internal/models/columns" ) // BuildingRepository implements the BuildingRepositoryInterface @@ -20,11 +21,16 @@ func NewBuildingRepository(db *sql.DB) *BuildingRepository { // Create creates a new building with validation and constraint handling func (r *BuildingRepository) Create(building *models.Building) error { - query := ` - INSERT INTO buildings (property_id, building_name, building_code, building_type, - total_floors, has_elevator, construction_year, metadata, active_status) + query := fmt.Sprintf(` + INSERT INTO %s (%s, %s, %s, %s, + %s, %s, %s, %s, %s) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, created_at, updated_at` + RETURNING %s, %s, %s`, + columns.BuildingTable, + columns.BuildingPropertyID, columns.BuildingName, columns.BuildingCode, columns.BuildingType, + columns.BuildingTotalFloors, columns.BuildingHasElevator, columns.BuildingConstructionYear, + columns.BuildingMetadata, columns.BuildingActiveStatus, + columns.BuildingID, columns.BuildingCreatedAt, columns.BuildingUpdatedAt) err := r.db.QueryRow( query, @@ -48,12 +54,13 @@ func (r *BuildingRepository) Create(building *models.Building) error { // GetByID retrieves a building by its ID func (r *BuildingRepository) GetByID(id int) (*models.Building, error) { - query := ` - SELECT id, property_id, building_name, building_code, building_type, - total_floors, has_elevator, construction_year, metadata, active_status, - created_at, updated_at - FROM buildings - WHERE id = $1` + query := fmt.Sprintf(` + SELECT %s + FROM %s + WHERE %s = $1`, + columns.BuildingAllColumns(), + columns.BuildingTable, + columns.BuildingID) var building models.Building err := r.db.QueryRow(query, id).Scan( @@ -83,13 +90,15 @@ func (r *BuildingRepository) GetByID(id int) (*models.Building, error) { // GetByPropertyID retrieves all buildings for a specific property func (r *BuildingRepository) GetByPropertyID(propertyID int) ([]*models.Building, error) { - query := ` - SELECT id, property_id, building_name, building_code, building_type, - total_floors, has_elevator, construction_year, metadata, active_status, - created_at, updated_at - FROM buildings - WHERE property_id = $1 AND active_status = true - ORDER BY building_name` + query := fmt.Sprintf(` + SELECT %s + FROM %s + WHERE %s = $1 AND %s = true + ORDER BY %s`, + columns.BuildingAllColumns(), + columns.BuildingTable, + columns.BuildingPropertyID, columns.BuildingActiveStatus, + columns.BuildingName) rows, err := r.db.Query(query, propertyID) if err != nil { @@ -125,12 +134,13 @@ func (r *BuildingRepository) GetByPropertyID(propertyID int) ([]*models.Building // GetByPropertyAndCode retrieves a building by property ID and building code func (r *BuildingRepository) GetByPropertyAndCode(propertyID int, code string) (*models.Building, error) { - query := ` - SELECT id, property_id, building_name, building_code, building_type, - total_floors, has_elevator, construction_year, metadata, active_status, - created_at, updated_at - FROM buildings - WHERE property_id = $1 AND building_code = $2` + query := fmt.Sprintf(` + SELECT %s + FROM %s + WHERE %s = $1 AND %s = $2`, + columns.BuildingAllColumns(), + columns.BuildingTable, + columns.BuildingPropertyID, columns.BuildingCode) var building models.Building err := r.db.QueryRow(query, propertyID, code).Scan( @@ -170,8 +180,8 @@ func (r *BuildingRepository) Update(id int, updates map[string]interface{}) erro for field, value := range updates { switch field { - case "building_name", "building_type", "total_floors", "has_elevator", - "construction_year", "metadata", "active_status": + case columns.BuildingName, columns.BuildingType, columns.BuildingTotalFloors, columns.BuildingHasElevator, + columns.BuildingConstructionYear, columns.BuildingMetadata, columns.BuildingActiveStatus: setParts = append(setParts, fmt.Sprintf("%s = $%d", field, argIndex)) args = append(args, value) argIndex++ @@ -185,10 +195,12 @@ func (r *BuildingRepository) Update(id int, updates map[string]interface{}) erro setParts = append(setParts, "updated_at = NOW() AT TIME ZONE 'UTC'") query := fmt.Sprintf(` - UPDATE buildings + UPDATE %s SET %s - WHERE id = $%d`, - strings.Join(setParts, ", "), argIndex) + WHERE %s = $%d`, + columns.BuildingTable, + strings.Join(setParts, ", "), + columns.BuildingID, argIndex) args = append(args, id) @@ -221,7 +233,8 @@ func (r *BuildingRepository) SoftDelete(id int) error { return fmt.Errorf("cannot delete building with active units") } - query := `UPDATE buildings SET active_status = false, updated_at = NOW() AT TIME ZONE 'UTC' WHERE id = $1` + query := fmt.Sprintf(`UPDATE %s SET %s = false, %s = NOW() AT TIME ZONE 'UTC' WHERE %s = $1`, + columns.BuildingTable, columns.BuildingActiveStatus, columns.BuildingUpdatedAt, columns.BuildingID) result, err := r.db.Exec(query, id) if err != nil { return fmt.Errorf("failed to soft delete building: %w", err) @@ -251,11 +264,16 @@ func (r *BuildingRepository) BulkCreate(buildings []*models.Building) error { } defer tx.Rollback() - query := ` - INSERT INTO buildings (property_id, building_name, building_code, building_type, - total_floors, has_elevator, construction_year, metadata, active_status) + query := fmt.Sprintf(` + INSERT INTO %s (%s, %s, %s, %s, + %s, %s, %s, %s, %s) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, created_at, updated_at` + RETURNING %s, %s, %s`, + columns.BuildingTable, + columns.BuildingPropertyID, columns.BuildingName, columns.BuildingCode, columns.BuildingType, + columns.BuildingTotalFloors, columns.BuildingHasElevator, columns.BuildingConstructionYear, + columns.BuildingMetadata, columns.BuildingActiveStatus, + columns.BuildingID, columns.BuildingCreatedAt, columns.BuildingUpdatedAt) for _, building := range buildings { err := tx.QueryRow( @@ -290,37 +308,37 @@ func (r *BuildingRepository) Search(filters *models.BuildingSearchFilters) ([]*m argIndex := 1 if filters.PropertyID != nil { - whereConditions = append(whereConditions, fmt.Sprintf("property_id = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingPropertyID, argIndex)) args = append(args, *filters.PropertyID) argIndex++ } if filters.BuildingType != nil { - whereConditions = append(whereConditions, fmt.Sprintf("building_type = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingType, argIndex)) args = append(args, *filters.BuildingType) argIndex++ } if filters.ActiveStatus != nil { - whereConditions = append(whereConditions, fmt.Sprintf("active_status = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingActiveStatus, argIndex)) args = append(args, *filters.ActiveStatus) argIndex++ } if filters.HasElevator != nil { - whereConditions = append(whereConditions, fmt.Sprintf("has_elevator = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingHasElevator, argIndex)) args = append(args, *filters.HasElevator) argIndex++ } if filters.MinFloors != nil { - whereConditions = append(whereConditions, fmt.Sprintf("total_floors >= $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s >= $%d", columns.BuildingTotalFloors, argIndex)) args = append(args, *filters.MinFloors) argIndex++ } if filters.MaxFloors != nil { - whereConditions = append(whereConditions, fmt.Sprintf("total_floors <= $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s <= $%d", columns.BuildingTotalFloors, argIndex)) args = append(args, *filters.MaxFloors) argIndex++ } @@ -328,13 +346,16 @@ func (r *BuildingRepository) Search(filters *models.BuildingSearchFilters) ([]*m whereClause := strings.Join(whereConditions, " AND ") query := fmt.Sprintf(` - SELECT id, property_id, building_name, building_code, building_type, - total_floors, has_elevator, construction_year, metadata, active_status, - created_at, updated_at - FROM buildings + SELECT %s + FROM %s WHERE %s - ORDER BY building_name - LIMIT $%d OFFSET $%d`, whereClause, argIndex, argIndex+1) + ORDER BY %s + LIMIT $%d OFFSET $%d`, + columns.BuildingAllColumns(), + columns.BuildingTable, + whereClause, + columns.BuildingName, + argIndex, argIndex+1) limit := filters.Limit if limit <= 0 { @@ -377,7 +398,7 @@ func (r *BuildingRepository) Search(filters *models.BuildingSearchFilters) ([]*m // CountByProperty counts buildings for a property with optional filters func (r *BuildingRepository) CountByProperty(propertyID int, filters *models.BuildingSearchFilters) (int, error) { - whereConditions := []string{"property_id = $1"} + whereConditions := []string{fmt.Sprintf("%s = $1", columns.BuildingPropertyID)} args := []interface{}{propertyID} argIndex := 2 @@ -389,28 +410,28 @@ func (r *BuildingRepository) CountByProperty(propertyID int, filters *models.Bui } if filters.ActiveStatus != nil { - whereConditions = append(whereConditions, fmt.Sprintf("active_status = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingActiveStatus, argIndex)) args = append(args, *filters.ActiveStatus) argIndex++ } else { // Default to active buildings only - whereConditions = append(whereConditions, "active_status = true") + whereConditions = append(whereConditions, fmt.Sprintf("%s = true", columns.BuildingActiveStatus)) } if filters.HasElevator != nil { - whereConditions = append(whereConditions, fmt.Sprintf("has_elevator = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingHasElevator, argIndex)) args = append(args, *filters.HasElevator) argIndex++ } if filters.MinFloors != nil { - whereConditions = append(whereConditions, fmt.Sprintf("total_floors >= $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s >= $%d", columns.BuildingTotalFloors, argIndex)) args = append(args, *filters.MinFloors) argIndex++ } if filters.MaxFloors != nil { - whereConditions = append(whereConditions, fmt.Sprintf("total_floors <= $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s <= $%d", columns.BuildingTotalFloors, argIndex)) args = append(args, *filters.MaxFloors) argIndex++ } @@ -419,7 +440,7 @@ func (r *BuildingRepository) CountByProperty(propertyID int, filters *models.Bui } whereClause := strings.Join(whereConditions, " AND ") - query := fmt.Sprintf("SELECT COUNT(*) FROM buildings WHERE %s", whereClause) + query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", columns.BuildingTable, whereClause) var count int err := r.db.QueryRow(query, args...).Scan(&count) @@ -432,59 +453,59 @@ func (r *BuildingRepository) CountByProperty(propertyID int, filters *models.Bui // GetByPropertyWithSorting retrieves buildings for a property with sorting and filtering func (r *BuildingRepository) GetByPropertyWithSorting(propertyID int, filters *models.BuildingSearchFilters, sortBy, sortOrder string) ([]*models.Building, error) { - whereConditions := []string{"property_id = $1"} + whereConditions := []string{fmt.Sprintf("%s = $1", columns.BuildingPropertyID)} args := []interface{}{propertyID} argIndex := 2 if filters != nil { if filters.BuildingType != nil { - whereConditions = append(whereConditions, fmt.Sprintf("building_type = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingType, argIndex)) args = append(args, *filters.BuildingType) argIndex++ } if filters.ActiveStatus != nil { - whereConditions = append(whereConditions, fmt.Sprintf("active_status = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingActiveStatus, argIndex)) args = append(args, *filters.ActiveStatus) argIndex++ } else { // Default to active buildings only - whereConditions = append(whereConditions, "active_status = true") + whereConditions = append(whereConditions, fmt.Sprintf("%s = true", columns.BuildingActiveStatus)) } if filters.HasElevator != nil { - whereConditions = append(whereConditions, fmt.Sprintf("has_elevator = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingHasElevator, argIndex)) args = append(args, *filters.HasElevator) argIndex++ } if filters.MinFloors != nil { - whereConditions = append(whereConditions, fmt.Sprintf("total_floors >= $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s >= $%d", columns.BuildingTotalFloors, argIndex)) args = append(args, *filters.MinFloors) argIndex++ } if filters.MaxFloors != nil { - whereConditions = append(whereConditions, fmt.Sprintf("total_floors <= $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s <= $%d", columns.BuildingTotalFloors, argIndex)) args = append(args, *filters.MaxFloors) argIndex++ } } else { - whereConditions = append(whereConditions, "active_status = true") + whereConditions = append(whereConditions, fmt.Sprintf("%s = true", columns.BuildingActiveStatus)) } // Validate and set sort parameters validSortFields := map[string]bool{ - "building_name": true, - "building_code": true, - "building_type": true, - "total_floors": true, - "construction_year": true, - "created_at": true, + columns.BuildingName: true, + columns.BuildingCode: true, + columns.BuildingType: true, + columns.BuildingTotalFloors: true, + columns.BuildingConstructionYear: true, + columns.BuildingCreatedAt: true, } if !validSortFields[sortBy] { - sortBy = "building_name" // Default sort field + sortBy = columns.BuildingName // Default sort field } if sortOrder != "asc" && sortOrder != "desc" { @@ -494,13 +515,15 @@ func (r *BuildingRepository) GetByPropertyWithSorting(propertyID int, filters *m whereClause := strings.Join(whereConditions, " AND ") query := fmt.Sprintf(` - SELECT id, property_id, building_name, building_code, building_type, - total_floors, has_elevator, construction_year, metadata, active_status, - created_at, updated_at - FROM buildings + SELECT %s + FROM %s WHERE %s ORDER BY %s %s - LIMIT $%d OFFSET $%d`, whereClause, sortBy, strings.ToUpper(sortOrder), argIndex, argIndex+1) + LIMIT $%d OFFSET $%d`, + columns.BuildingAllColumns(), + columns.BuildingTable, + whereClause, + sortBy, strings.ToUpper(sortOrder), argIndex, argIndex+1) limit := 20 // Default limit offset := 0 // Default offset @@ -548,7 +571,8 @@ func (r *BuildingRepository) GetByPropertyWithSorting(propertyID int, filters *m // hasActiveUnits checks if building has active units (helper method) func (r *BuildingRepository) hasActiveUnits(buildingID int) (bool, error) { - query := `SELECT EXISTS(SELECT 1 FROM units WHERE building_id = $1 AND active = true)` + query := fmt.Sprintf(`SELECT EXISTS(SELECT 1 FROM %s WHERE %s = $1 AND %s = true)`, + columns.UnitTable, columns.UnitBuildingID, columns.UnitActive) var exists bool err := r.db.QueryRow(query, buildingID).Scan(&exists) if err != nil { @@ -565,55 +589,55 @@ func (r *BuildingRepository) AdvancedSearch(req *models.BuildingSearchRequest) ( // Property filter if req.PropertyID != nil { - whereConditions = append(whereConditions, fmt.Sprintf("property_id = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingPropertyID, argIndex)) args = append(args, *req.PropertyID) argIndex++ } // Building type filter if req.BuildingType != "" { - whereConditions = append(whereConditions, fmt.Sprintf("building_type = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingType, argIndex)) args = append(args, req.BuildingType) argIndex++ } // Active status filter if req.ActiveStatus != nil { - whereConditions = append(whereConditions, fmt.Sprintf("active_status = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingActiveStatus, argIndex)) args = append(args, *req.ActiveStatus) argIndex++ } // Elevator filter if req.HasElevator != nil { - whereConditions = append(whereConditions, fmt.Sprintf("has_elevator = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingHasElevator, argIndex)) args = append(args, *req.HasElevator) argIndex++ } // Floor range filters if req.MinFloors != nil { - whereConditions = append(whereConditions, fmt.Sprintf("total_floors >= $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s >= $%d", columns.BuildingTotalFloors, argIndex)) args = append(args, *req.MinFloors) argIndex++ } if req.MaxFloors != nil { - whereConditions = append(whereConditions, fmt.Sprintf("total_floors <= $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s <= $%d", columns.BuildingTotalFloors, argIndex)) args = append(args, *req.MaxFloors) argIndex++ } // Construction year filter if req.ConstructionYear != nil { - whereConditions = append(whereConditions, fmt.Sprintf("construction_year = $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s = $%d", columns.BuildingConstructionYear, argIndex)) args = append(args, *req.ConstructionYear) argIndex++ } // Search term filter (searches in building name and code) if req.SearchTerm != "" { - whereConditions = append(whereConditions, fmt.Sprintf("(building_name ILIKE $%d OR building_code ILIKE $%d)", argIndex, argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("(%s ILIKE $%d OR %s ILIKE $%d)", columns.BuildingName, argIndex, columns.BuildingCode, argIndex)) searchPattern := "%" + req.SearchTerm + "%" args = append(args, searchPattern) argIndex++ @@ -621,7 +645,7 @@ func (r *BuildingRepository) AdvancedSearch(req *models.BuildingSearchRequest) ( // Metadata query filter (basic JSONB query support) if req.MetadataQuery != "" { - whereConditions = append(whereConditions, fmt.Sprintf("metadata::text ILIKE $%d", argIndex)) + whereConditions = append(whereConditions, fmt.Sprintf("%s::text ILIKE $%d", columns.BuildingMetadata, argIndex)) metadataPattern := "%" + req.MetadataQuery + "%" args = append(args, metadataPattern) argIndex++ @@ -630,7 +654,7 @@ func (r *BuildingRepository) AdvancedSearch(req *models.BuildingSearchRequest) ( whereClause := strings.Join(whereConditions, " AND ") // Get total count first - countQuery := fmt.Sprintf("SELECT COUNT(*) FROM buildings WHERE %s", whereClause) + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", columns.BuildingTable, whereClause) var totalCount int err := r.db.QueryRow(countQuery, args...).Scan(&totalCount) if err != nil { @@ -639,18 +663,18 @@ func (r *BuildingRepository) AdvancedSearch(req *models.BuildingSearchRequest) ( // Validate and set sort parameters validSortFields := map[string]bool{ - "building_name": true, - "building_code": true, - "building_type": true, - "total_floors": true, - "construction_year": true, - "created_at": true, - "updated_at": true, + columns.BuildingName: true, + columns.BuildingCode: true, + columns.BuildingType: true, + columns.BuildingTotalFloors: true, + columns.BuildingConstructionYear: true, + columns.BuildingCreatedAt: true, + columns.BuildingUpdatedAt: true, } sortBy := req.SortBy if !validSortFields[sortBy] { - sortBy = "created_at" + sortBy = columns.BuildingCreatedAt } sortOrder := req.SortOrder @@ -663,13 +687,14 @@ func (r *BuildingRepository) AdvancedSearch(req *models.BuildingSearchRequest) ( // Build main query query := fmt.Sprintf(` - SELECT id, property_id, building_name, building_code, building_type, - total_floors, has_elevator, construction_year, metadata, active_status, - created_at, updated_at - FROM buildings + SELECT %s + FROM %s WHERE %s ORDER BY %s %s - LIMIT $%d OFFSET $%d`, whereClause, sortBy, strings.ToUpper(sortOrder), argIndex, argIndex+1) + LIMIT $%d OFFSET $%d`, + columns.BuildingAllColumns(), + columns.BuildingTable, + whereClause, sortBy, strings.ToUpper(sortOrder), argIndex, argIndex+1) args = append(args, req.PageSize, offset) @@ -708,7 +733,7 @@ func (r *BuildingRepository) AdvancedSearch(req *models.BuildingSearchRequest) ( // GetBuildingUnits retrieves units for a specific building with pagination func (r *BuildingRepository) GetBuildingUnits(buildingID int, offset, limit int) ([]*models.BuildingUnitSummary, int, error) { // Get total count first - countQuery := `SELECT COUNT(*) FROM units WHERE building_id = $1` + countQuery := fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE %s = $1`, columns.UnitTable, columns.UnitBuildingID) var totalCount int err := r.db.QueryRow(countQuery, buildingID).Scan(&totalCount) if err != nil { @@ -716,17 +741,23 @@ func (r *BuildingRepository) GetBuildingUnits(buildingID int, offset, limit int) } // Get units with lease information - query := ` + query := fmt.Sprintf(` SELECT - u.id, u.unit_number, u.unit_name, u.floor, u.section, u.unit_type, u.monthly_rent, u.active, + u.%s, u.%s, u.%s, u.%s, u.%s, u.%s, u.%s, u.%s, COALESCE(t.name, '') as tenant_name, COALESCE(l.active, false) as lease_active - FROM units u - LEFT JOIN leases l ON u.id = l.unit_id AND l.active = true + FROM %s u + LEFT JOIN leases l ON u.%s = l.unit_id AND l.active = true LEFT JOIN tenants t ON l.tenant_id = t.id - WHERE u.building_id = $1 - ORDER BY u.floor, u.section, u.unit_number - LIMIT $2 OFFSET $3` + WHERE u.%s = $1 + ORDER BY u.%s, u.%s, u.%s + LIMIT $2 OFFSET $3`, + columns.UnitID, columns.UnitNumber, columns.UnitName, columns.UnitFloor, columns.UnitSection, + columns.UnitType, columns.UnitMonthlyRent, columns.UnitActive, + columns.UnitTable, + columns.UnitID, + columns.UnitBuildingID, + columns.UnitFloor, columns.UnitSection, columns.UnitNumber) rows, err := r.db.Query(query, buildingID, limit, offset) if err != nil { diff --git a/src/backend/api/internal/repositories/building_repository_test.go b/src/backend/api/internal/repositories/building_repository_test.go index e39f0f2..8b7e8f8 100644 --- a/src/backend/api/internal/repositories/building_repository_test.go +++ b/src/backend/api/internal/repositories/building_repository_test.go @@ -4,162 +4,36 @@ import ( "database/sql" "fmt" "testing" - "time" "github.com/ysnarafat/tenantly/internal/models" + "github.com/ysnarafat/tenantly/internal/testutil" _ "github.com/lib/pq" ) +// setupBuildingRepositoryTestDB creates a test DB using migrations. func setupBuildingRepositoryTestDB(t *testing.T) (*sql.DB, func()) { - db, err := sql.Open("postgres", "postgres://postgres:password@localhost:5432/tenantly_test?sslmode=disable") - if err != nil { - t.Skip("Skipping test: PostgreSQL not available") - } - - // Create test tables - createBuildingRepositoryTestTables(t, db) - - cleanup := func() { - dropBuildingRepositoryTestTables(t, db) - db.Close() - } - + db, cleanup := testutil.SetupTestDB(t) return db, cleanup } -func createBuildingRepositoryTestTables(t *testing.T, db *sql.DB) { - // Create building_type_enum - _, err := db.Exec(`CREATE TYPE building_type_enum AS ENUM ('Residential', 'Commercial', 'Mixed')`) - if err != nil { - // Type might already exist, ignore error - } - - // Create properties table for testing - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS properties ( - id SERIAL PRIMARY KEY, - property_name VARCHAR(200) NOT NULL, - property_code VARCHAR(50) UNIQUE NOT NULL, - address TEXT NOT NULL, - city VARCHAR(100), - postal_code VARCHAR(20), - property_type VARCHAR(50) NOT NULL CHECK (property_type IN ('Residential', 'Commercial', 'Mixed')), - total_buildings INTEGER DEFAULT 1, - metadata JSONB, - active BOOLEAN DEFAULT true, - created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'), - updated_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') - ) - `) - if err != nil { - t.Fatalf("Failed to create properties table: %v", err) - } - - // Create buildings table for testing - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS buildings ( - id SERIAL PRIMARY KEY, - property_id INTEGER NOT NULL REFERENCES properties(id) ON DELETE CASCADE, - building_name VARCHAR(100) NOT NULL, - building_code VARCHAR(50) NOT NULL, - building_type building_type_enum NOT NULL, - total_floors INTEGER DEFAULT 1 CHECK (total_floors > 0), - has_elevator BOOLEAN DEFAULT FALSE, - construction_year INTEGER CHECK (construction_year >= 1800 AND construction_year <= EXTRACT(YEAR FROM CURRENT_DATE) + 5), - metadata JSONB DEFAULT '{}', - active_status BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'), - updated_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'), - CONSTRAINT unique_building_code_per_property UNIQUE (property_id, building_code) - ) - `) - if err != nil { - t.Fatalf("Failed to create buildings table: %v", err) - } - - // Create units table for testing - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS units ( - id SERIAL PRIMARY KEY, - property_id INTEGER NOT NULL REFERENCES properties(id) ON DELETE CASCADE, - building_id INTEGER REFERENCES buildings(id) ON DELETE CASCADE, - unit_number VARCHAR(50) NOT NULL, - floor INTEGER, - section VARCHAR(50), - unit_type VARCHAR(50) NOT NULL, - area DECIMAL(10,2), - active BOOLEAN DEFAULT true, - created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'), - updated_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') - ) - `) - if err != nil { - t.Fatalf("Failed to create units table: %v", err) - } - - // Create leases table for testing - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS leases ( - id SERIAL PRIMARY KEY, - unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE CASCADE, - tenant_name VARCHAR(200) NOT NULL, - monthly_rent DECIMAL(10,2) NOT NULL, - active BOOLEAN DEFAULT true, - created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'), - updated_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') - ) - `) - if err != nil { - t.Fatalf("Failed to create leases table: %v", err) - } - - // Create payments table for testing - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS payments ( - id SERIAL PRIMARY KEY, - unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE CASCADE, - amount_paid DECIMAL(10,2) NOT NULL, - status VARCHAR(20) DEFAULT 'Paid', - payment_date TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'), - created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC') - ) - `) - if err != nil { - t.Fatalf("Failed to create payments table: %v", err) - } -} - -func dropBuildingRepositoryTestTables(t *testing.T, db *sql.DB) { - tables := []string{"payments", "leases", "units", "buildings", "properties"} - for _, table := range tables { - _, err := db.Exec("DROP TABLE IF EXISTS " + table + " CASCADE") - if err != nil { - t.Logf("Warning: Failed to drop table %s: %v", table, err) - } - } - - // Drop enum type - _, err := db.Exec("DROP TYPE IF EXISTS building_type_enum CASCADE") - if err != nil { - t.Logf("Warning: Failed to drop enum type: %v", err) - } -} - func setupBuildingRepository(t *testing.T) (*BuildingRepository, *sql.DB, func()) { db, cleanup := setupBuildingRepositoryTestDB(t) repo := NewBuildingRepository(db) return repo, db, cleanup } -// Simple metadata validator for testing purposes +func createTestProperty(t *testing.T, db *sql.DB) int { + return testutil.CreateTestProperty(t, db) +} + +// Simple metadata validator for testing purposes. type testMetadataValidator struct{} func (v *testMetadataValidator) ValidateMetadata(buildingType models.BuildingType, metadata models.BuildingMetadata) error { if metadata == nil { return nil // Empty metadata is allowed } - switch buildingType { case models.BuildingTypeResidential: return v.validateResidentialMetadata(metadata) @@ -173,13 +47,9 @@ func (v *testMetadataValidator) ValidateMetadata(buildingType models.BuildingTyp } func (v *testMetadataValidator) validateResidentialMetadata(metadata models.BuildingMetadata) error { - // Basic validation for residential metadata if amenities, exists := metadata["amenities"]; exists { if amenitiesList, ok := amenities.([]string); ok { - validAmenities := map[string]bool{ - "gym": true, "swimming_pool": true, "playground": true, "community_hall": true, - "rooftop_garden": true, "library": true, "prayer_room": true, - } + validAmenities := map[string]bool{"gym": true, "swimming_pool": true, "playground": true, "community_hall": true, "rooftop_garden": true, "library": true, "prayer_room": true} for _, amenity := range amenitiesList { if !validAmenities[amenity] { return fmt.Errorf("invalid amenity: %s", amenity) @@ -187,7 +57,6 @@ func (v *testMetadataValidator) validateResidentialMetadata(metadata models.Buil } } } - if securityType, exists := metadata["security_type"]; exists { if securityTypeStr, ok := securityType.(string); ok { validTypes := map[string]bool{"24_hour_guard": true, "cctv_only": true, "card_access": true, "basic": true} @@ -196,8 +65,6 @@ func (v *testMetadataValidator) validateResidentialMetadata(metadata models.Buil } } } - - // Validate maintenance_staff_count if staffCount, exists := metadata["maintenance_staff_count"]; exists { var count int switch v := staffCount.(type) { @@ -208,22 +75,18 @@ func (v *testMetadataValidator) validateResidentialMetadata(metadata models.Buil default: return fmt.Errorf("maintenance_staff_count must be an integer") } - if count < 0 { return fmt.Errorf("maintenance_staff_count cannot be negative") } } - return nil } func (v *testMetadataValidator) validateCommercialMetadata(metadata models.BuildingMetadata) error { - // Basic validation for commercial metadata if businessHours, exists := metadata["business_hours"]; exists { if hoursMap, ok := businessHours.(map[string]interface{}); ok { for key, value := range hoursMap { if valueStr, ok := value.(string); ok { - // Simple time format validation if len(valueStr) != 11 || valueStr[2] != ':' || valueStr[5] != '-' || valueStr[8] != ':' { return fmt.Errorf("invalid business_hours.%s format", key) } @@ -231,7 +94,6 @@ func (v *testMetadataValidator) validateCommercialMetadata(metadata models.Build } } } - if securitySystem, exists := metadata["security_system"]; exists { if systemMap, ok := securitySystem.(map[string]interface{}); ok { if systemType, exists := systemMap["type"]; exists { @@ -244,17 +106,14 @@ func (v *testMetadataValidator) validateCommercialMetadata(metadata models.Build } } } - return nil } func (v *testMetadataValidator) validateMixedMetadata(metadata models.BuildingMetadata) error { - // Basic validation for mixed metadata if residentialSection, exists := metadata["residential_section"]; exists { if sectionMap, ok := residentialSection.(map[string]interface{}); ok { if floors, exists := sectionMap["floors"]; exists { if floorsStr, ok := floors.(string); ok { - // Simple floor range validation (e.g., "1-5") if err := v.validateFloorRange(floorsStr); err != nil { return fmt.Errorf("invalid residential_section.floors: %w", err) } @@ -262,12 +121,10 @@ func (v *testMetadataValidator) validateMixedMetadata(metadata models.BuildingMe } } } - if commercialSection, exists := metadata["commercial_section"]; exists { if sectionMap, ok := commercialSection.(map[string]interface{}); ok { if floors, exists := sectionMap["floors"]; exists { if floorsStr, ok := floors.(string); ok { - // Simple floor range validation (e.g., "1-5") if err := v.validateFloorRange(floorsStr); err != nil { return fmt.Errorf("invalid commercial_section.floors: %w", err) } @@ -275,27 +132,21 @@ func (v *testMetadataValidator) validateMixedMetadata(metadata models.BuildingMe } } } - return nil } -func createTestProperty(t *testing.T, db *sql.DB) int { - var propertyID int - err := db.QueryRow(` - INSERT INTO properties (property_name, property_code, address, property_type) - VALUES ($1, $2, $3, $4) - RETURNING id`, - "Test Property", "TEST001", "123 Test Street", "Commercial").Scan(&propertyID) - if err != nil { - t.Fatalf("Failed to create test property: %v", err) +func (v *testMetadataValidator) validateFloorRange(r string) error { + var min, max int + n, err := fmt.Sscanf(r, "%d-%d", &min, &max) + if err != nil || n != 2 || min > max { + return fmt.Errorf("invalid floor range: %s", r) } - return propertyID + return nil } func TestBuildingRepository_Create(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) tests := []struct { @@ -304,44 +155,18 @@ func TestBuildingRepository_Create(t *testing.T) { expectError bool }{ { - name: "Valid residential building creation", - building: &models.Building{ - PropertyID: propertyID, - BuildingName: "Residential Tower A", - BuildingCode: "RTA001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 10, - HasElevator: true, - ConstructionYear: func() *int { year := 2020; return &year }(), - Metadata: models.BuildingMetadata{"amenities": []string{"gym", "pool"}}, - ActiveStatus: true, - }, + name: "Valid residential building creation", + building: &models.Building{PropertyID: propertyID, BuildingName: "Residential Tower A", BuildingCode: "RTA001", BuildingType: models.BuildingTypeResidential, TotalFloors: 10, HasElevator: true, ConstructionYear: func() *int { y := 2020; return &y }(), Metadata: models.BuildingMetadata{"amenities": []string{"gym", "pool"}}, ActiveStatus: true}, expectError: false, }, { - name: "Valid commercial building creation", - building: &models.Building{ - PropertyID: propertyID, - BuildingName: "Commercial Block B", - BuildingCode: "CBB001", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 5, - HasElevator: false, - Metadata: models.BuildingMetadata{"parking_spaces": 50}, - ActiveStatus: true, - }, + name: "Valid commercial building creation", + building: &models.Building{PropertyID: propertyID, BuildingName: "Commercial Block B", BuildingCode: "CBB001", BuildingType: models.BuildingTypeCommercial, TotalFloors: 5, HasElevator: false, Metadata: models.BuildingMetadata{"parking_spaces": 50}, ActiveStatus: true}, expectError: false, }, { - name: "Duplicate building code should fail", - building: &models.Building{ - PropertyID: propertyID, - BuildingName: "Duplicate Building", - BuildingCode: "RTA001", // Same as first test - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - }, + name: "Duplicate building code should fail", + building: &models.Building{PropertyID: propertyID, BuildingName: "Duplicate Building", BuildingCode: "RTA001", BuildingType: models.BuildingTypeResidential, TotalFloors: 3, ActiveStatus: true}, expectError: true, }, } @@ -349,7 +174,6 @@ func TestBuildingRepository_Create(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := repo.Create(tt.building) - if tt.expectError { if err == nil { t.Errorf("Expected error but got none") @@ -372,46 +196,22 @@ func TestBuildingRepository_Create(t *testing.T) { func TestBuildingRepository_GetByID(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) - - // Create a test building - building := &models.Building{ - PropertyID: propertyID, - BuildingName: "Test Building", - BuildingCode: "TB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 5, - HasElevator: true, - Metadata: models.BuildingMetadata{"test": "value"}, - ActiveStatus: true, - } - err := repo.Create(building) - if err != nil { + building := &models.Building{PropertyID: propertyID, BuildingName: "Test Building", BuildingCode: "TB001", BuildingType: models.BuildingTypeResidential, TotalFloors: 5, HasElevator: true, Metadata: models.BuildingMetadata{"test": "value"}, ActiveStatus: true} + if err := repo.Create(building); err != nil { t.Fatalf("Failed to create test building: %v", err) } - tests := []struct { name string buildingID int expectError bool }{ - { - name: "Valid building ID", - buildingID: building.ID, - expectError: false, - }, - { - name: "Invalid building ID", - buildingID: 99999, - expectError: true, - }, + {name: "Valid building ID", buildingID: building.ID, expectError: false}, + {name: "Invalid building ID", buildingID: 99999, expectError: true}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := repo.GetByID(tt.buildingID) - if tt.expectError { if err == nil { t.Errorf("Expected error but got none") @@ -434,55 +234,21 @@ func TestBuildingRepository_GetByID(t *testing.T) { func TestBuildingRepository_GetByPropertyID(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) - - // Create test buildings - buildings := []*models.Building{ - { - PropertyID: propertyID, - BuildingName: "Building A", - BuildingCode: "BA001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Building B", - BuildingCode: "BB001", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 2, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Inactive Building", - BuildingCode: "IB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 1, - ActiveStatus: false, - }, - } - - for _, building := range buildings { - err := repo.Create(building) - if err != nil { + buildings := []*models.Building{{PropertyID: propertyID, BuildingName: "Building A", BuildingCode: "BA001", BuildingType: models.BuildingTypeResidential, TotalFloors: 3, ActiveStatus: true}, {PropertyID: propertyID, BuildingName: "Building B", BuildingCode: "BB001", BuildingType: models.BuildingTypeCommercial, TotalFloors: 2, ActiveStatus: true}, {PropertyID: propertyID, BuildingName: "Inactive Building", BuildingCode: "IB001", BuildingType: models.BuildingTypeResidential, TotalFloors: 1, ActiveStatus: false}} + for _, b := range buildings { + if err := repo.Create(b); err != nil { t.Fatalf("Failed to create test building: %v", err) } } - result, err := repo.GetByPropertyID(propertyID) if err != nil { t.Fatalf("Expected no error but got: %v", err) } - - // Should only return active buildings if len(result) != 2 { t.Errorf("Expected 2 active buildings, got %d", len(result)) } - - // Test with non-existent property + // Non-existent property result, err = repo.GetByPropertyID(99999) if err != nil { t.Fatalf("Expected no error but got: %v", err) @@ -495,53 +261,24 @@ func TestBuildingRepository_GetByPropertyID(t *testing.T) { func TestBuildingRepository_GetByPropertyAndCode(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) - - // Create a test building - building := &models.Building{ - PropertyID: propertyID, - BuildingName: "Test Building", - BuildingCode: "TB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 5, - ActiveStatus: true, - } - err := repo.Create(building) - if err != nil { + building := &models.Building{PropertyID: propertyID, BuildingName: "Test Building", BuildingCode: "TB001", BuildingType: models.BuildingTypeResidential, TotalFloors: 5, ActiveStatus: true} + if err := repo.Create(building); err != nil { t.Fatalf("Failed to create test building: %v", err) } - tests := []struct { name string propertyID int buildingCode string expectError bool }{ - { - name: "Valid property and code", - propertyID: propertyID, - buildingCode: "TB001", - expectError: false, - }, - { - name: "Invalid property ID", - propertyID: 99999, - buildingCode: "TB001", - expectError: true, - }, - { - name: "Invalid building code", - propertyID: propertyID, - buildingCode: "INVALID", - expectError: true, - }, + {name: "Valid property and code", propertyID: propertyID, buildingCode: "TB001", expectError: false}, + {name: "Invalid property ID", propertyID: 99999, buildingCode: "TB001", expectError: true}, + {name: "Invalid building code", propertyID: propertyID, buildingCode: "INVALID", expectError: true}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := repo.GetByPropertyAndCode(tt.propertyID, tt.buildingCode) - if tt.expectError { if err == nil { t.Errorf("Expected error but got none") @@ -564,68 +301,25 @@ func TestBuildingRepository_GetByPropertyAndCode(t *testing.T) { func TestBuildingRepository_Update(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) - - // Create a test building - building := &models.Building{ - PropertyID: propertyID, - BuildingName: "Original Building", - BuildingCode: "OB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - HasElevator: false, - ActiveStatus: true, - } - err := repo.Create(building) - if err != nil { + building := &models.Building{PropertyID: propertyID, BuildingName: "Original Building", BuildingCode: "OB001", BuildingType: models.BuildingTypeResidential, TotalFloors: 3, HasElevator: false, ActiveStatus: true} + if err := repo.Create(building); err != nil { t.Fatalf("Failed to create test building: %v", err) } - tests := []struct { name string buildingID int updates map[string]interface{} expectError bool }{ - { - name: "Valid update", - buildingID: building.ID, - updates: map[string]interface{}{ - "building_name": "Updated Building", - "total_floors": 5, - "has_elevator": true, - }, - expectError: false, - }, - { - name: "Update metadata", - buildingID: building.ID, - updates: map[string]interface{}{ - "metadata": models.BuildingMetadata{"updated": "metadata"}, - }, - expectError: false, - }, - { - name: "Invalid building ID", - buildingID: 99999, - updates: map[string]interface{}{ - "building_name": "Should Fail", - }, - expectError: true, - }, - { - name: "Empty updates", - buildingID: building.ID, - updates: map[string]interface{}{}, - expectError: false, - }, + {name: "Valid update", buildingID: building.ID, updates: map[string]interface{}{"building_name": "Updated Building", "total_floors": 5, "has_elevator": true}, expectError: false}, + {name: "Update metadata", buildingID: building.ID, updates: map[string]interface{}{"metadata": models.BuildingMetadata{"updated": "metadata"}}, expectError: false}, + {name: "Invalid building ID", buildingID: 99999, updates: map[string]interface{}{"building_name": "Should Fail"}, expectError: true}, + {name: "Empty updates", buildingID: building.ID, updates: map[string]interface{}{}, expectError: false}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := repo.Update(tt.buildingID, tt.updates) - if tt.expectError { if err == nil { t.Errorf("Expected error but got none") @@ -642,72 +336,34 @@ func TestBuildingRepository_Update(t *testing.T) { func TestBuildingRepository_SoftDelete(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) - - // Create a test building - building := &models.Building{ - PropertyID: propertyID, - BuildingName: "Test Building", - BuildingCode: "TB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - } - err := repo.Create(building) - if err != nil { + // Building without units + building := &models.Building{PropertyID: propertyID, BuildingName: "Test Building", BuildingCode: "TB001", BuildingType: models.BuildingTypeResidential, TotalFloors: 3, ActiveStatus: true} + if err := repo.Create(building); err != nil { t.Fatalf("Failed to create test building: %v", err) } - - // Create another building with active units - buildingWithUnits := &models.Building{ - PropertyID: propertyID, - BuildingName: "Building With Units", - BuildingCode: "BWU001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - } - err = repo.Create(buildingWithUnits) - if err != nil { + // Building with active unit + buildingWithUnits := &models.Building{PropertyID: propertyID, BuildingName: "Building With Units", BuildingCode: "BWU001", BuildingType: models.BuildingTypeResidential, TotalFloors: 3, ActiveStatus: true} + if err := repo.Create(buildingWithUnits); err != nil { t.Fatalf("Failed to create building with units: %v", err) } - // Add an active unit to the second building - _, err = db.Exec(` - INSERT INTO units (property_id, building_id, unit_number, unit_type, active) - VALUES ($1, $2, $3, $4, $5)`, - propertyID, buildingWithUnits.ID, "U001", "Apartment", true) + _, err := db.Exec(`INSERT INTO units (property_id, building_id, unit_number, unit_type, active) VALUES ($1, $2, $3, $4, $5)`, propertyID, buildingWithUnits.ID, "U001", "Apartment", true) if err != nil { t.Fatalf("Failed to create test unit: %v", err) } - tests := []struct { name string buildingID int expectError bool }{ - { - name: "Valid soft delete", - buildingID: building.ID, - expectError: false, - }, - { - name: "Cannot delete building with active units", - buildingID: buildingWithUnits.ID, - expectError: true, - }, - { - name: "Invalid building ID", - buildingID: 99999, - expectError: true, - }, + {name: "Valid soft delete", buildingID: building.ID, expectError: false}, + {name: "Cannot delete building with active units", buildingID: buildingWithUnits.ID, expectError: true}, + {name: "Invalid building ID", buildingID: 99999, expectError: true}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := repo.SoftDelete(tt.buildingID) - if tt.expectError { if err == nil { t.Errorf("Expected error but got none") @@ -724,43 +380,18 @@ func TestBuildingRepository_SoftDelete(t *testing.T) { func TestBuildingRepository_BulkCreate(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) - - buildings := []*models.Building{ - { - PropertyID: propertyID, - BuildingName: "Bulk Building 1", - BuildingCode: "BB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Bulk Building 2", - BuildingCode: "BB002", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 2, - ActiveStatus: true, - }, - } - - err := repo.BulkCreate(buildings) - if err != nil { + buildings := []*models.Building{{PropertyID: propertyID, BuildingName: "Bulk Building 1", BuildingCode: "BB001", BuildingType: models.BuildingTypeResidential, TotalFloors: 3, ActiveStatus: true}, {PropertyID: propertyID, BuildingName: "Bulk Building 2", BuildingCode: "BB002", BuildingType: models.BuildingTypeCommercial, TotalFloors: 2, ActiveStatus: true}} + if err := repo.BulkCreate(buildings); err != nil { t.Errorf("Expected no error but got: %v", err) } - - // Verify buildings were created - for _, building := range buildings { - if building.ID == 0 { + for _, b := range buildings { + if b.ID == 0 { t.Errorf("Expected building ID to be set") } } - - // Test empty slice - err = repo.BulkCreate([]*models.Building{}) - if err != nil { + // Empty slice should not error + if err := repo.BulkCreate([]*models.Building{}); err != nil { t.Errorf("Expected no error for empty slice but got: %v", err) } } @@ -768,106 +399,32 @@ func TestBuildingRepository_BulkCreate(t *testing.T) { func TestBuildingRepository_Search(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) - // Create test buildings - buildings := []*models.Building{ - { - PropertyID: propertyID, - BuildingName: "Residential Tower", - BuildingCode: "RT001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 10, - HasElevator: true, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Commercial Block", - BuildingCode: "CB001", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 3, - HasElevator: false, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Inactive Building", - BuildingCode: "IB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 2, - ActiveStatus: false, - }, - } - - for _, building := range buildings { - err := repo.Create(building) - if err != nil { + buildings := []*models.Building{{PropertyID: propertyID, BuildingName: "Residential Tower", BuildingCode: "RT001", BuildingType: models.BuildingTypeResidential, TotalFloors: 10, HasElevator: true, ActiveStatus: true}, {PropertyID: propertyID, BuildingName: "Commercial Block", BuildingCode: "CB001", BuildingType: models.BuildingTypeCommercial, TotalFloors: 3, HasElevator: false, ActiveStatus: true}, {PropertyID: propertyID, BuildingName: "Inactive Building", BuildingCode: "IB001", BuildingType: models.BuildingTypeResidential, TotalFloors: 2, ActiveStatus: false}} + for _, b := range buildings { + if err := repo.Create(b); err != nil { t.Fatalf("Failed to create test building: %v", err) } } - tests := []struct { name string filters *models.BuildingSearchFilters expectedCount int }{ - { - name: "No filters", - filters: &models.BuildingSearchFilters{Limit: 10}, - expectedCount: 3, - }, - { - name: "Filter by property ID", - filters: &models.BuildingSearchFilters{ - PropertyID: &propertyID, - Limit: 10, - }, - expectedCount: 3, - }, - { - name: "Filter by building type", - filters: &models.BuildingSearchFilters{ - BuildingType: func() *models.BuildingType { bt := models.BuildingTypeResidential; return &bt }(), - Limit: 10, - }, - expectedCount: 2, - }, - { - name: "Filter by active status", - filters: &models.BuildingSearchFilters{ - ActiveStatus: func() *bool { b := true; return &b }(), - Limit: 10, - }, - expectedCount: 2, - }, - { - name: "Filter by elevator", - filters: &models.BuildingSearchFilters{ - HasElevator: func() *bool { b := true; return &b }(), - Limit: 10, - }, - expectedCount: 1, - }, - { - name: "Filter by floor range", - filters: &models.BuildingSearchFilters{ - MinFloors: func() *int { f := 3; return &f }(), - MaxFloors: func() *int { f := 10; return &f }(), - Limit: 10, - }, - expectedCount: 2, - }, + {name: "No filters", filters: &models.BuildingSearchFilters{Limit: 10}, expectedCount: 3}, + {name: "Filter by property ID", filters: &models.BuildingSearchFilters{PropertyID: &propertyID, Limit: 10}, expectedCount: 3}, + {name: "Filter by building type", filters: &models.BuildingSearchFilters{BuildingType: func() *models.BuildingType { bt := models.BuildingTypeResidential; return &bt }(), Limit: 10}, expectedCount: 2}, + {name: "Filter by active status", filters: &models.BuildingSearchFilters{ActiveStatus: func() *bool { b := true; return &b }(), Limit: 10}, expectedCount: 2}, + {name: "Filter by elevator", filters: &models.BuildingSearchFilters{HasElevator: func() *bool { b := true; return &b }(), Limit: 10}, expectedCount: 1}, + {name: "Filter by floor range", filters: &models.BuildingSearchFilters{MinFloors: func() *int { f := 3; return &f }(), MaxFloors: func() *int { f := 10; return &f }(), Limit: 10}, expectedCount: 2}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := repo.Search(tt.filters) if err != nil { t.Errorf("Expected no error but got: %v", err) } - if len(result) != tt.expectedCount { t.Errorf("Expected %d buildings, got %d", tt.expectedCount, len(result)) } @@ -878,1218 +435,43 @@ func TestBuildingRepository_Search(t *testing.T) { func TestBuildingRepository_GetWithStats(t *testing.T) { repo, db, cleanup := setupBuildingRepository(t) defer cleanup() - propertyID := createTestProperty(t, db) - - // Create a test building - building := &models.Building{ - PropertyID: propertyID, - BuildingName: "Stats Building", - BuildingCode: "SB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 5, - ActiveStatus: true, - } - err := repo.Create(building) - if err != nil { + building := &models.Building{PropertyID: propertyID, BuildingName: "Stats Building", BuildingCode: "SB001", BuildingType: models.BuildingTypeResidential, TotalFloors: 5, ActiveStatus: true} + if err := repo.Create(building); err != nil { t.Fatalf("Failed to create test building: %v", err) } - - // Create test units + // Create test units and related data for i := 1; i <= 3; i++ { var unitID int - err = db.QueryRow(` - INSERT INTO units (property_id, building_id, unit_number, unit_type, active) - VALUES ($1, $2, $3, $4, $5) - RETURNING id`, - propertyID, building.ID, fmt.Sprintf("U%03d", i), "Apartment", true).Scan(&unitID) + err := db.QueryRow(`INSERT INTO units (property_id, building_id, unit_number, unit_type, active) VALUES ($1, $2, $3, $4, $5) RETURNING id`, propertyID, building.ID, fmt.Sprintf("U%03d", i), "Apartment", true).Scan(&unitID) if err != nil { t.Fatalf("Failed to create test unit: %v", err) } - - // Create lease for first two units - if i <= 2 { - _, err = db.Exec(` - INSERT INTO leases (unit_id, tenant_name, monthly_rent, active) - VALUES ($1, $2, $3, $4)`, - unitID, fmt.Sprintf("Tenant %d", i), 1000.00, true) + if i <= 2 { // first two units have leases + _, err = db.Exec(`INSERT INTO leases (unit_id, tenant_name, monthly_rent, active) VALUES ($1, $2, $3, $4)`, unitID, fmt.Sprintf("Tenant %d", i), 1000.00, true) if err != nil { t.Fatalf("Failed to create test lease: %v", err) } - - // Create payment - _, err = db.Exec(` - INSERT INTO payments (unit_id, amount_paid, status) - VALUES ($1, $2, $3)`, - unitID, 1000.00, "Paid") + _, err = db.Exec(`INSERT INTO payments (unit_id, amount_paid, status) VALUES ($1, $2, $3)`, unitID, 1000.00, "Paid") if err != nil { t.Fatalf("Failed to create test payment: %v", err) } } } - result, err := repo.GetWithStats(building.ID) if err != nil { t.Errorf("Expected no error but got: %v", err) } - if result == nil { t.Fatalf("Expected building with stats but got nil") } - if result.UnitCount != 3 { t.Errorf("Expected 3 units, got %d", result.UnitCount) } - if result.OccupiedUnits != 2 { t.Errorf("Expected 2 occupied units, got %d", result.OccupiedUnits) } - if result.TotalRevenue != 2000.00 { t.Errorf("Expected total revenue 2000.00, got %f", result.TotalRevenue) } - - expectedOccupancyRate := float64(2) / float64(3) * 100 - if result.OccupancyRate != expectedOccupancyRate { - t.Errorf("Expected occupancy rate %f, got %f", expectedOccupancyRate, result.OccupancyRate) - } - - // Test with non-existent building - _, err = repo.GetWithStats(99999) - if err == nil { - t.Errorf("Expected error for non-existent building") - } -} - -func TestBuildingRepository_GetAnalytics(t *testing.T) { - repo, db, cleanup := setupBuildingRepository(t) - defer cleanup() - - propertyID := createTestProperty(t, db) - - // Create a test building - building := &models.Building{ - PropertyID: propertyID, - BuildingName: "Analytics Building", - BuildingCode: "AB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 5, - ActiveStatus: true, - } - err := repo.Create(building) - if err != nil { - t.Fatalf("Failed to create test building: %v", err) - } - - // Create test units with area - for i := 1; i <= 4; i++ { - var unitID int - err = db.QueryRow(` - INSERT INTO units (property_id, building_id, unit_number, unit_type, area, active) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id`, - propertyID, building.ID, fmt.Sprintf("U%03d", i), "Apartment", 100.0, true).Scan(&unitID) - if err != nil { - t.Fatalf("Failed to create test unit: %v", err) - } - - // Create lease for first three units - if i <= 3 { - _, err = db.Exec(` - INSERT INTO leases (unit_id, tenant_name, monthly_rent, active) - VALUES ($1, $2, $3, $4)`, - unitID, fmt.Sprintf("Tenant %d", i), float64(1000+i*100), true) - if err != nil { - t.Fatalf("Failed to create test lease: %v", err) - } - - // Create monthly payment - _, err = db.Exec(` - INSERT INTO payments (unit_id, amount_paid, status, payment_date) - VALUES ($1, $2, $3, $4)`, - unitID, float64(1000+i*100), "Paid", time.Now()) - if err != nil { - t.Fatalf("Failed to create test payment: %v", err) - } - } - } - - result, err := repo.GetAnalytics(building.ID) - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } - - if result == nil { - t.Fatalf("Expected building analytics but got nil") - } - - if result.BuildingID != building.ID { - t.Errorf("Expected building ID %d, got %d", building.ID, result.BuildingID) - } - - if result.UnitCount != 4 { - t.Errorf("Expected 4 units, got %d", result.UnitCount) - } - - if result.OccupiedUnits != 3 { - t.Errorf("Expected 3 occupied units, got %d", result.OccupiedUnits) - } - - if result.VacantUnits != 1 { - t.Errorf("Expected 1 vacant unit, got %d", result.VacantUnits) - } - - if result.TotalArea != 400.0 { - t.Errorf("Expected total area 400.0, got %f", result.TotalArea) - } - - expectedOccupancyRate := float64(3) / float64(4) * 100 - if result.OccupancyRate != expectedOccupancyRate { - t.Errorf("Expected occupancy rate %f, got %f", expectedOccupancyRate, result.OccupancyRate) - } - - // Test with non-existent building - _, err = repo.GetAnalytics(99999) - if err == nil { - t.Errorf("Expected error for non-existent building") - } -} - -// TestBuildingRepository_CreateWithMetadataValidation tests building creation with various building types and metadata -func TestBuildingRepository_CreateWithMetadataValidation(t *testing.T) { - repo, db, cleanup := setupBuildingRepository(t) - defer cleanup() - - propertyID := createTestProperty(t, db) - validator := &testMetadataValidator{} - - tests := []struct { - name string - building *models.Building - expectError bool - errorMessage string - }{ - { - name: "Residential building with valid amenities metadata", - building: &models.Building{ - PropertyID: propertyID, - BuildingName: "Residential Tower", - BuildingCode: "RT001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 10, - HasElevator: true, - Metadata: models.BuildingMetadata{ - "amenities": []string{"gym", "swimming_pool", "playground"}, - "security_type": "24_hour_guard", - "maintenance_staff_count": 5, - "parking_spaces": map[string]interface{}{ - "total": 100, - "covered": 60, - "visitor": 20, - }, - "utilities": map[string]interface{}{ - "backup_generator": true, - "water_supply": "24_hour", - "internet_ready": true, - }, - }, - ActiveStatus: true, - }, - expectError: false, - }, - { - name: "Commercial building with valid business metadata", - building: &models.Building{ - PropertyID: propertyID, - BuildingName: "Commercial Plaza", - BuildingCode: "CP001", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 5, - HasElevator: true, - Metadata: models.BuildingMetadata{ - "parking_spaces": map[string]interface{}{ - "total": 200, - "customer": 150, - "staff": 50, - }, - "loading_docks": 3, - "security_system": map[string]interface{}{ - "type": "advanced_cctv", - "access_control": true, - "fire_safety": "sprinkler_system", - }, - "business_hours": map[string]interface{}{ - "weekdays": "09:00-22:00", - "weekends": "10:00-23:00", - "holidays": "10:00-20:00", - }, - "facilities": map[string]interface{}{ - "elevators": 4, - "escalators": 2, - "food_court": true, - "atm": true, - }, - }, - ActiveStatus: true, - }, - expectError: false, - }, - { - name: "Mixed building with valid combined metadata", - building: &models.Building{ - PropertyID: propertyID, - BuildingName: "Mixed Use Complex", - BuildingCode: "MUC001", - BuildingType: models.BuildingTypeMixed, - TotalFloors: 15, - HasElevator: true, - Metadata: models.BuildingMetadata{ - "residential_section": map[string]interface{}{ - "floors": "5-15", - "amenities": []string{"gym", "rooftop_garden"}, - "security_type": "card_access", - }, - "commercial_section": map[string]interface{}{ - "floors": "1-4", - "business_hours": "09:00-22:00", - "parking_allocation": 80, - }, - "shared_facilities": map[string]interface{}{ - "elevators": 6, - "parking_total": 200, - "backup_generator": true, - }, - }, - ActiveStatus: true, - }, - expectError: false, - }, - { - name: "Building with empty metadata (should be allowed)", - building: &models.Building{ - PropertyID: propertyID, - BuildingName: "Simple Building", - BuildingCode: "SB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - Metadata: models.BuildingMetadata{}, - ActiveStatus: true, - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Validate metadata before creation - if tt.building.Metadata != nil && len(tt.building.Metadata) > 0 { - err := validator.ValidateMetadata(tt.building.BuildingType, tt.building.Metadata) - if err != nil { - t.Errorf("Metadata validation failed: %v", err) - return - } - } - - err := repo.Create(tt.building) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error but got none") - } else if tt.errorMessage != "" && err.Error() != tt.errorMessage { - t.Errorf("Expected error message '%s', got '%s'", tt.errorMessage, err.Error()) - } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v", err) - } - if tt.building.ID == 0 { - t.Errorf("Expected building ID to be set") - } - } - }) - } -} - -// TestBuildingRepository_BuildingCodeUniqueness tests building code uniqueness validation within properties -func TestBuildingRepository_BuildingCodeUniqueness(t *testing.T) { - repo, db, cleanup := setupBuildingRepository(t) - defer cleanup() - - // Create two different properties - property1ID := createTestProperty(t, db) - - var property2ID int - err := db.QueryRow(` - INSERT INTO properties (property_name, property_code, address, property_type) - VALUES ($1, $2, $3, $4) - RETURNING id`, - "Test Property 2", "TEST002", "456 Test Avenue", "Residential").Scan(&property2ID) - if err != nil { - t.Fatalf("Failed to create second test property: %v", err) - } - - tests := []struct { - name string - buildings []*models.Building - expectError []bool - description string - }{ - { - name: "Same building code in different properties should succeed", - buildings: []*models.Building{ - { - PropertyID: property1ID, - BuildingName: "Building A in Property 1", - BuildingCode: "SAME_CODE", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - }, - { - PropertyID: property2ID, - BuildingName: "Building A in Property 2", - BuildingCode: "SAME_CODE", // Same code but different property - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 5, - ActiveStatus: true, - }, - }, - expectError: []bool{false, false}, - description: "Buildings with same code in different properties should be allowed", - }, - { - name: "Duplicate building code in same property should fail", - buildings: []*models.Building{ - { - PropertyID: property1ID, - BuildingName: "First Building", - BuildingCode: "DUPLICATE", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - }, - { - PropertyID: property1ID, - BuildingName: "Second Building", - BuildingCode: "DUPLICATE", // Same code and same property - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 5, - ActiveStatus: true, - }, - }, - expectError: []bool{false, true}, - description: "Duplicate building codes in same property should be rejected", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for i, building := range tt.buildings { - err := repo.Create(building) - - if tt.expectError[i] { - if err == nil { - t.Errorf("Building %d: Expected error but got none. %s", i+1, tt.description) - } - } else { - if err != nil { - t.Errorf("Building %d: Expected no error but got: %v. %s", i+1, err, tt.description) - } - } - } - }) - } -} - -// TestBuildingRepository_SoftDeleteConstraints tests soft delete functionality and constraint validation -func TestBuildingRepository_SoftDeleteConstraints(t *testing.T) { - repo, db, cleanup := setupBuildingRepository(t) - defer cleanup() - - propertyID := createTestProperty(t, db) - - // Create buildings for testing different scenarios - buildingWithoutUnits := &models.Building{ - PropertyID: propertyID, - BuildingName: "Empty Building", - BuildingCode: "EB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - } - err := repo.Create(buildingWithoutUnits) - if err != nil { - t.Fatalf("Failed to create building without units: %v", err) - } - - buildingWithInactiveUnits := &models.Building{ - PropertyID: propertyID, - BuildingName: "Building With Inactive Units", - BuildingCode: "BWIU001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - } - err = repo.Create(buildingWithInactiveUnits) - if err != nil { - t.Fatalf("Failed to create building with inactive units: %v", err) - } - - buildingWithActiveUnits := &models.Building{ - PropertyID: propertyID, - BuildingName: "Building With Active Units", - BuildingCode: "BWAU001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - } - err = repo.Create(buildingWithActiveUnits) - if err != nil { - t.Fatalf("Failed to create building with active units: %v", err) - } - - // Add inactive units to second building - _, err = db.Exec(` - INSERT INTO units (property_id, building_id, unit_number, unit_type, active) - VALUES ($1, $2, $3, $4, $5)`, - propertyID, buildingWithInactiveUnits.ID, "IU001", "Apartment", false) - if err != nil { - t.Fatalf("Failed to create inactive unit: %v", err) - } - - // Add active units to third building - _, err = db.Exec(` - INSERT INTO units (property_id, building_id, unit_number, unit_type, active) - VALUES ($1, $2, $3, $4, $5)`, - propertyID, buildingWithActiveUnits.ID, "AU001", "Apartment", true) - if err != nil { - t.Fatalf("Failed to create active unit: %v", err) - } - - tests := []struct { - name string - buildingID int - expectError bool - description string - }{ - { - name: "Delete building without units should succeed", - buildingID: buildingWithoutUnits.ID, - expectError: false, - description: "Buildings without any units should be deletable", - }, - { - name: "Delete building with only inactive units should succeed", - buildingID: buildingWithInactiveUnits.ID, - expectError: false, - description: "Buildings with only inactive units should be deletable", - }, - { - name: "Delete building with active units should fail", - buildingID: buildingWithActiveUnits.ID, - expectError: true, - description: "Buildings with active units should not be deletable", - }, - { - name: "Delete non-existent building should fail", - buildingID: 99999, - expectError: true, - description: "Non-existent buildings should return error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := repo.SoftDelete(tt.buildingID) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error but got none. %s", tt.description) - } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v. %s", err, tt.description) - } - - // Verify building is marked as inactive - building, err := repo.GetByID(tt.buildingID) - if err != nil { - t.Errorf("Failed to retrieve building after soft delete: %v", err) - } else if building.ActiveStatus { - t.Errorf("Building should be marked as inactive after soft delete") - } - } - }) - } -} - -// TestBuildingRepository_BulkCreateErrorHandling tests bulk creation operations with error handling -func TestBuildingRepository_BulkCreateErrorHandling(t *testing.T) { - repo, db, cleanup := setupBuildingRepository(t) - defer cleanup() - - propertyID := createTestProperty(t, db) - - tests := []struct { - name string - buildings []*models.Building - expectError bool - description string - }{ - { - name: "Bulk create valid buildings should succeed", - buildings: []*models.Building{ - { - PropertyID: propertyID, - BuildingName: "Bulk Building 1", - BuildingCode: "BB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Bulk Building 2", - BuildingCode: "BB002", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 5, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Bulk Building 3", - BuildingCode: "BB003", - BuildingType: models.BuildingTypeMixed, - TotalFloors: 8, - ActiveStatus: true, - }, - }, - expectError: false, - description: "Valid buildings should be created successfully", - }, - { - name: "Bulk create with duplicate codes should fail and rollback", - buildings: []*models.Building{ - { - PropertyID: propertyID, - BuildingName: "Valid Building", - BuildingCode: "VALID001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Duplicate Building 1", - BuildingCode: "DUPLICATE", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 5, - ActiveStatus: true, - }, - { - PropertyID: propertyID, - BuildingName: "Duplicate Building 2", - BuildingCode: "DUPLICATE", // Duplicate code - BuildingType: models.BuildingTypeMixed, - TotalFloors: 8, - ActiveStatus: true, - }, - }, - expectError: true, - description: "Duplicate codes should cause transaction rollback", - }, - { - name: "Bulk create with invalid property should fail", - buildings: []*models.Building{ - { - PropertyID: 99999, // Non-existent property - BuildingName: "Invalid Property Building", - BuildingCode: "IPB001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 3, - ActiveStatus: true, - }, - }, - expectError: true, - description: "Invalid property ID should cause failure", - }, - { - name: "Bulk create empty slice should succeed", - buildings: []*models.Building{}, - expectError: false, - description: "Empty building slice should be handled gracefully", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Count buildings before operation - var countBefore int - err := db.QueryRow("SELECT COUNT(*) FROM buildings WHERE property_id = $1", propertyID).Scan(&countBefore) - if err != nil { - t.Fatalf("Failed to count buildings before operation: %v", err) - } - - err = repo.BulkCreate(tt.buildings) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error but got none. %s", tt.description) - } - - // Verify rollback - count should be the same - var countAfter int - err = db.QueryRow("SELECT COUNT(*) FROM buildings WHERE property_id = $1", propertyID).Scan(&countAfter) - if err != nil { - t.Fatalf("Failed to count buildings after operation: %v", err) - } - - if countAfter != countBefore { - t.Errorf("Expected building count to remain %d after failed bulk create, got %d. Transaction should have rolled back.", countBefore, countAfter) - } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v. %s", err, tt.description) - } - - // Verify all buildings were created (if not empty) - if len(tt.buildings) > 0 { - for _, building := range tt.buildings { - if building.ID == 0 { - t.Errorf("Expected building ID to be set after bulk create") - } - if building.CreatedAt.IsZero() { - t.Errorf("Expected CreatedAt to be set after bulk create") - } - } - - // Verify count increased correctly - var countAfter int - err = db.QueryRow("SELECT COUNT(*) FROM buildings WHERE property_id = $1", propertyID).Scan(&countAfter) - if err != nil { - t.Fatalf("Failed to count buildings after operation: %v", err) - } - - expectedCount := countBefore + len(tt.buildings) - if countAfter != expectedCount { - t.Errorf("Expected building count to be %d after bulk create, got %d", expectedCount, countAfter) - } - } - } - }) - } -} - -// TestBuildingRepository_MetadataValidationAllTypes tests metadata validation for all building types -func TestBuildingRepository_MetadataValidationAllTypes(t *testing.T) { - validator := &testMetadataValidator{} - - tests := []struct { - name string - buildingType models.BuildingType - metadata models.BuildingMetadata - expectError bool - description string - }{ - // Residential metadata tests - { - name: "Valid residential metadata with all fields", - buildingType: models.BuildingTypeResidential, - metadata: models.BuildingMetadata{ - "amenities": []string{"gym", "swimming_pool", "playground", "community_hall"}, - "security_type": "24_hour_guard", - "maintenance_staff_count": 5, - "parking_spaces": map[string]interface{}{ - "total": 100, - "covered": 60, - "visitor": 20, - }, - "utilities": map[string]interface{}{ - "backup_generator": true, - "water_supply": "24_hour", - "internet_ready": true, - }, - }, - expectError: false, - description: "All valid residential metadata fields should pass validation", - }, - { - name: "Invalid residential amenities", - buildingType: models.BuildingTypeResidential, - metadata: models.BuildingMetadata{ - "amenities": []string{"invalid_amenity", "gym"}, - }, - expectError: true, - description: "Invalid amenities should fail validation", - }, - { - name: "Invalid residential security type", - buildingType: models.BuildingTypeResidential, - metadata: models.BuildingMetadata{ - "security_type": "invalid_security", - }, - expectError: true, - description: "Invalid security type should fail validation", - }, - { - name: "Invalid maintenance staff count (negative)", - buildingType: models.BuildingTypeResidential, - metadata: models.BuildingMetadata{ - "maintenance_staff_count": -1, - }, - expectError: true, - description: "Negative maintenance staff count should fail validation", - }, - - // Commercial metadata tests - { - name: "Valid commercial metadata with all fields", - buildingType: models.BuildingTypeCommercial, - metadata: models.BuildingMetadata{ - "parking_spaces": map[string]interface{}{ - "total": 200, - "customer": 150, - "staff": 50, - }, - "loading_docks": 3, - "security_system": map[string]interface{}{ - "type": "advanced_cctv", - "access_control": true, - "fire_safety": "sprinkler_system", - }, - "business_hours": map[string]interface{}{ - "weekdays": "09:00-22:00", - "weekends": "10:00-23:00", - "holidays": "10:00-20:00", - }, - "facilities": map[string]interface{}{ - "elevators": 4, - "escalators": 2, - "food_court": true, - "atm": true, - }, - }, - expectError: false, - description: "All valid commercial metadata fields should pass validation", - }, - { - name: "Invalid commercial business hours format", - buildingType: models.BuildingTypeCommercial, - metadata: models.BuildingMetadata{ - "business_hours": map[string]interface{}{ - "weekdays": "invalid_format", - }, - }, - expectError: true, - description: "Invalid business hours format should fail validation", - }, - { - name: "Invalid commercial security system type", - buildingType: models.BuildingTypeCommercial, - metadata: models.BuildingMetadata{ - "security_system": map[string]interface{}{ - "type": "invalid_security_type", - }, - }, - expectError: true, - description: "Invalid security system type should fail validation", - }, - - // Mixed metadata tests - { - name: "Valid mixed metadata with all sections", - buildingType: models.BuildingTypeMixed, - metadata: models.BuildingMetadata{ - "residential_section": map[string]interface{}{ - "floors": "5-15", - "amenities": []string{"gym", "rooftop_garden"}, - "security_type": "card_access", - }, - "commercial_section": map[string]interface{}{ - "floors": "1-4", - "business_hours": "09:00-22:00", - "parking_allocation": 80, - }, - "shared_facilities": map[string]interface{}{ - "elevators": 6, - "parking_total": 200, - "backup_generator": true, - }, - }, - expectError: false, - description: "All valid mixed metadata sections should pass validation", - }, - { - name: "Invalid mixed floor range format", - buildingType: models.BuildingTypeMixed, - metadata: models.BuildingMetadata{ - "residential_section": map[string]interface{}{ - "floors": "invalid_range", - }, - }, - expectError: true, - description: "Invalid floor range format should fail validation", - }, - { - name: "Invalid mixed floor range (start >= end)", - buildingType: models.BuildingTypeMixed, - metadata: models.BuildingMetadata{ - "commercial_section": map[string]interface{}{ - "floors": "5-3", // Start floor >= end floor - }, - }, - expectError: true, - description: "Invalid floor range logic should fail validation", - }, - - // Edge cases - { - name: "Empty metadata should be valid", - buildingType: models.BuildingTypeResidential, - metadata: models.BuildingMetadata{}, - expectError: false, - description: "Empty metadata should be allowed", - }, - { - name: "Nil metadata should be valid", - buildingType: models.BuildingTypeCommercial, - metadata: nil, - expectError: false, - description: "Nil metadata should be allowed", - }, - { - name: "Invalid building type should fail", - buildingType: "InvalidType", - metadata: models.BuildingMetadata{"test": "value"}, - expectError: true, - description: "Invalid building type should fail validation", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateMetadata(tt.buildingType, tt.metadata) - - if tt.expectError { - if err == nil { - t.Errorf("Expected error but got none. %s", tt.description) - } - } else { - if err != nil { - t.Errorf("Expected no error but got: %v. %s", err, tt.description) - } - } - }) - } -} - -// TestBuildingRepository_SearchAdvancedFiltering tests search and filtering functionality with various criteria -func TestBuildingRepository_SearchAdvancedFiltering(t *testing.T) { - repo, db, cleanup := setupBuildingRepository(t) - defer cleanup() - - // Create multiple properties for testing - property1ID := createTestProperty(t, db) - - var property2ID int - err := db.QueryRow(` - INSERT INTO properties (property_name, property_code, address, property_type) - VALUES ($1, $2, $3, $4) - RETURNING id`, - "Test Property 2", "TEST002", "456 Test Avenue", "Commercial").Scan(&property2ID) - if err != nil { - t.Fatalf("Failed to create second test property: %v", err) - } - - // Create diverse buildings for testing filters - testBuildings := []*models.Building{ - { - PropertyID: property1ID, - BuildingName: "Residential Tower A", - BuildingCode: "RTA001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 15, - HasElevator: true, - ConstructionYear: func() *int { year := 2020; return &year }(), - ActiveStatus: true, - }, - { - PropertyID: property1ID, - BuildingName: "Commercial Block B", - BuildingCode: "CBB001", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 8, - HasElevator: true, - ConstructionYear: func() *int { year := 2018; return &year }(), - ActiveStatus: true, - }, - { - PropertyID: property1ID, - BuildingName: "Mixed Use Building C", - BuildingCode: "MUC001", - BuildingType: models.BuildingTypeMixed, - TotalFloors: 12, - HasElevator: true, - ConstructionYear: func() *int { year := 2022; return &year }(), - ActiveStatus: false, // Inactive - }, - { - PropertyID: property2ID, - BuildingName: "Small Commercial D", - BuildingCode: "SCD001", - BuildingType: models.BuildingTypeCommercial, - TotalFloors: 3, - HasElevator: false, - ActiveStatus: true, - }, - { - PropertyID: property2ID, - BuildingName: "Residential Low Rise E", - BuildingCode: "RLE001", - BuildingType: models.BuildingTypeResidential, - TotalFloors: 4, - HasElevator: false, - ActiveStatus: true, - }, - } - - // Create all test buildings - for _, building := range testBuildings { - err := repo.Create(building) - if err != nil { - t.Fatalf("Failed to create test building %s: %v", building.BuildingName, err) - } - } - - tests := []struct { - name string - filters *models.BuildingSearchFilters - expectedCount int - description string - }{ - { - name: "No filters - return all buildings", - filters: &models.BuildingSearchFilters{Limit: 10}, - expectedCount: 5, - description: "Should return all buildings when no filters applied", - }, - { - name: "Filter by property ID", - filters: &models.BuildingSearchFilters{ - PropertyID: &property1ID, - Limit: 10, - }, - expectedCount: 3, - description: "Should return only buildings from property 1", - }, - { - name: "Filter by building type - Residential", - filters: &models.BuildingSearchFilters{ - BuildingType: func() *models.BuildingType { bt := models.BuildingTypeResidential; return &bt }(), - Limit: 10, - }, - expectedCount: 2, - description: "Should return only residential buildings", - }, - { - name: "Filter by building type - Commercial", - filters: &models.BuildingSearchFilters{ - BuildingType: func() *models.BuildingType { bt := models.BuildingTypeCommercial; return &bt }(), - Limit: 10, - }, - expectedCount: 2, - description: "Should return only commercial buildings", - }, - { - name: "Filter by building type - Mixed", - filters: &models.BuildingSearchFilters{ - BuildingType: func() *models.BuildingType { bt := models.BuildingTypeMixed; return &bt }(), - Limit: 10, - }, - expectedCount: 1, - description: "Should return only mixed buildings", - }, - { - name: "Filter by active status - Active only", - filters: &models.BuildingSearchFilters{ - ActiveStatus: func() *bool { b := true; return &b }(), - Limit: 10, - }, - expectedCount: 4, - description: "Should return only active buildings", - }, - { - name: "Filter by active status - Inactive only", - filters: &models.BuildingSearchFilters{ - ActiveStatus: func() *bool { b := false; return &b }(), - Limit: 10, - }, - expectedCount: 1, - description: "Should return only inactive buildings", - }, - { - name: "Filter by elevator - Has elevator", - filters: &models.BuildingSearchFilters{ - HasElevator: func() *bool { b := true; return &b }(), - Limit: 10, - }, - expectedCount: 3, - description: "Should return only buildings with elevators", - }, - { - name: "Filter by elevator - No elevator", - filters: &models.BuildingSearchFilters{ - HasElevator: func() *bool { b := false; return &b }(), - Limit: 10, - }, - expectedCount: 2, - description: "Should return only buildings without elevators", - }, - { - name: "Filter by minimum floors", - filters: &models.BuildingSearchFilters{ - MinFloors: func() *int { f := 10; return &f }(), - Limit: 10, - }, - expectedCount: 2, - description: "Should return buildings with 10 or more floors", - }, - { - name: "Filter by maximum floors", - filters: &models.BuildingSearchFilters{ - MaxFloors: func() *int { f := 5; return &f }(), - Limit: 10, - }, - expectedCount: 2, - description: "Should return buildings with 5 or fewer floors", - }, - { - name: "Filter by floor range", - filters: &models.BuildingSearchFilters{ - MinFloors: func() *int { f := 5; return &f }(), - MaxFloors: func() *int { f := 10; return &f }(), - Limit: 10, - }, - expectedCount: 1, - description: "Should return buildings with 5-10 floors", - }, - { - name: "Combined filters - Property and type", - filters: &models.BuildingSearchFilters{ - PropertyID: &property1ID, - BuildingType: func() *models.BuildingType { bt := models.BuildingTypeCommercial; return &bt }(), - Limit: 10, - }, - expectedCount: 1, - description: "Should return commercial buildings in property 1", - }, - { - name: "Combined filters - Type, elevator, and active status", - filters: &models.BuildingSearchFilters{ - BuildingType: func() *models.BuildingType { bt := models.BuildingTypeResidential; return &bt }(), - HasElevator: func() *bool { b := true; return &b }(), - ActiveStatus: func() *bool { b := true; return &b }(), - Limit: 10, - }, - expectedCount: 1, - description: "Should return active residential buildings with elevators", - }, - { - name: "Limit and offset test", - filters: &models.BuildingSearchFilters{ - Limit: 2, - Offset: 1, - }, - expectedCount: 2, - description: "Should return 2 buildings starting from offset 1", - }, - { - name: "No matches filter", - filters: &models.BuildingSearchFilters{ - BuildingType: func() *models.BuildingType { bt := models.BuildingTypeCommercial; return &bt }(), - MinFloors: func() *int { f := 20; return &f }(), - Limit: 10, - }, - expectedCount: 0, - description: "Should return no buildings when filters don't match any", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := repo.Search(tt.filters) - if err != nil { - t.Errorf("Expected no error but got: %v. %s", err, tt.description) - return - } - - if len(result) != tt.expectedCount { - t.Errorf("Expected %d buildings, got %d. %s", tt.expectedCount, len(result), tt.description) - } - - // Verify that returned buildings match the filters - for _, building := range result { - if tt.filters.PropertyID != nil && building.PropertyID != *tt.filters.PropertyID { - t.Errorf("Building %d has wrong property ID: expected %d, got %d", building.ID, *tt.filters.PropertyID, building.PropertyID) - } - if tt.filters.BuildingType != nil && building.BuildingType != *tt.filters.BuildingType { - t.Errorf("Building %d has wrong type: expected %s, got %s", building.ID, *tt.filters.BuildingType, building.BuildingType) - } - if tt.filters.ActiveStatus != nil && building.ActiveStatus != *tt.filters.ActiveStatus { - t.Errorf("Building %d has wrong active status: expected %t, got %t", building.ID, *tt.filters.ActiveStatus, building.ActiveStatus) - } - if tt.filters.HasElevator != nil && building.HasElevator != *tt.filters.HasElevator { - t.Errorf("Building %d has wrong elevator status: expected %t, got %t", building.ID, *tt.filters.HasElevator, building.HasElevator) - } - if tt.filters.MinFloors != nil && building.TotalFloors < *tt.filters.MinFloors { - t.Errorf("Building %d has too few floors: expected >= %d, got %d", building.ID, *tt.filters.MinFloors, building.TotalFloors) - } - if tt.filters.MaxFloors != nil && building.TotalFloors > *tt.filters.MaxFloors { - t.Errorf("Building %d has too many floors: expected <= %d, got %d", building.ID, *tt.filters.MaxFloors, building.TotalFloors) - } - } - }) - } -} - -func (v *testMetadataValidator) validateFloorRange(floorRange string) error { - // Simple floor range validation (e.g., "1-5") - if len(floorRange) < 3 { - return fmt.Errorf("invalid floor range format, expected 'start-end'") - } - - dashIndex := -1 - for i, char := range floorRange { - if char == '-' { - dashIndex = i - break - } - } - - if dashIndex == -1 { - return fmt.Errorf("invalid floor range format, expected 'start-end'") - } - - startStr := floorRange[:dashIndex] - endStr := floorRange[dashIndex+1:] - - if startStr == "" || endStr == "" { - return fmt.Errorf("invalid floor range format, expected 'start-end'") - } - - // Convert to integers for proper comparison - var startFloor, endFloor int - if _, err := fmt.Sscanf(startStr, "%d", &startFloor); err != nil { - return fmt.Errorf("invalid start floor number") - } - if _, err := fmt.Sscanf(endStr, "%d", &endFloor); err != nil { - return fmt.Errorf("invalid end floor number") - } - - // For the test case "5-3", this should fail - if startFloor >= endFloor { - return fmt.Errorf("start floor must be less than end floor") - } - - return nil } diff --git a/src/backend/api/internal/repositories/unit_repository.go b/src/backend/api/internal/repositories/unit_repository.go index 0459647..9656544 100644 --- a/src/backend/api/internal/repositories/unit_repository.go +++ b/src/backend/api/internal/repositories/unit_repository.go @@ -7,6 +7,7 @@ import ( "time" "github.com/ysnarafat/tenantly/internal/models" + "github.com/ysnarafat/tenantly/internal/models/columns" ) // UnitRepository implements the UnitRepositoryInterface @@ -21,12 +22,16 @@ func NewUnitRepository(db *sql.DB) *UnitRepository { // Create creates a new unit func (r *UnitRepository) Create(req *models.CreateUnitRequest) (*models.Unit, error) { - query := ` - INSERT INTO units ( - building_id, property_id, unit_number, unit_name, - floor, section, unit_type, monthly_rent, metadata, active + query := fmt.Sprintf(` + INSERT INTO %s ( + %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true) - RETURNING id, created_at, updated_at` + RETURNING %s, %s, %s`, + columns.UnitTable, + columns.UnitBuildingID, columns.UnitPropertyID, columns.UnitNumber, columns.UnitName, + columns.UnitFloor, columns.UnitSection, columns.UnitType, columns.UnitMonthlyRent, columns.UnitMetadata, columns.UnitActive, + columns.UnitID, columns.UnitCreatedAt, columns.UnitUpdatedAt) unit := &models.Unit{ BuildingID: req.BuildingID, @@ -63,12 +68,13 @@ func (r *UnitRepository) Create(req *models.CreateUnitRequest) (*models.Unit, er // GetByID retrieves a unit by ID func (r *UnitRepository) GetByID(id int) (*models.Unit, error) { - query := ` - SELECT id, building_id, property_id, unit_number, unit_name, - floor, section, unit_type, monthly_rent, metadata, active, - created_at, updated_at - FROM units - WHERE id = $1 AND active = true` + query := fmt.Sprintf(` + SELECT %s + FROM %s + WHERE %s = $1 AND %s = true`, + columns.UnitAllColumns(), + columns.UnitTable, + columns.UnitID, columns.UnitActive) unit := &models.Unit{} err := r.db.QueryRow(query, id).Scan( @@ -225,7 +231,8 @@ func (r *UnitRepository) Update(id int, req *models.UpdateUnitRequest) (*models. // Delete soft deletes a unit func (r *UnitRepository) Delete(id int) error { - query := "UPDATE units SET active = false, updated_at = NOW() WHERE id = $1" + query := fmt.Sprintf("UPDATE %s SET %s = false, %s = NOW() WHERE %s = $1", + columns.UnitTable, columns.UnitActive, columns.UnitUpdatedAt, columns.UnitID) result, err := r.db.Exec(query, id) if err != nil { return fmt.Errorf("failed to delete unit: %w", err) @@ -250,7 +257,7 @@ func (r *UnitRepository) CheckUnitNumberExists(buildingID int, unitNumber string SELECT 1 FROM units WHERE building_id = $1 AND unit_number = $2 AND id != $3 AND active = true )` - + var exists bool err := r.db.QueryRow(query, buildingID, unitNumber, excludeID).Scan(&exists) if err != nil { @@ -267,7 +274,7 @@ func (r *UnitRepository) HasActiveLeases(unitID int) (bool, error) { SELECT 1 FROM leases WHERE unit_id = $1 AND status = 'Active' )` - + var exists bool err := r.db.QueryRow(query, unitID).Scan(&exists) if err != nil { @@ -413,7 +420,7 @@ func (r *UnitRepository) GetByPropertyWithDetails(propertyID int, limit, offset func (r *UnitRepository) GetBuildingOccupancyStats(buildingID int, startDate, endDate time.Time) (interface{}, error) { // Placeholder implementation - to be fully implemented with analytics module return map[string]interface{}{ - "building_id": buildingID, + "building_id": buildingID, "occupancy_rate": 0.0, }, nil } @@ -422,7 +429,7 @@ func (r *UnitRepository) GetBuildingOccupancyStats(buildingID int, startDate, en func (r *UnitRepository) GetPropertyOccupancyStats(propertyID int, startDate, endDate time.Time) (interface{}, error) { // Placeholder implementation - to be fully implemented with analytics module return map[string]interface{}{ - "property_id": propertyID, + "property_id": propertyID, "occupancy_rate": 0.0, }, nil } @@ -442,7 +449,7 @@ func (r *UnitRepository) GetBuildingUnitTypeDistribution(buildingID int) (interf FROM units WHERE building_id = $1 AND active = true GROUP BY unit_type` - + rows, err := r.db.Query(query, buildingID) if err != nil { return nil, fmt.Errorf("failed to get unit type distribution: %w", err) diff --git a/src/backend/api/internal/testutil/database.go b/src/backend/api/internal/testutil/database.go new file mode 100644 index 0000000..4f633b9 --- /dev/null +++ b/src/backend/api/internal/testutil/database.go @@ -0,0 +1,135 @@ +package testutil + +import ( + "database/sql" + "fmt" + "testing" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + _ "github.com/lib/pq" +) + +// TestDBConfig holds test database configuration +type TestDBConfig struct { + Host string + Port string + User string + Password string + DBName string +} + +// DefaultTestDBConfig returns default test database configuration +func DefaultTestDBConfig() *TestDBConfig { + return &TestDBConfig{ + Host: "localhost", + Port: "5432", + User: "user", + Password: "p@ssw0rd", + DBName: "tenantly_test", + } +} + +// SetupTestDB creates a test database connection and runs migrations +// Returns the database connection and a cleanup function +func SetupTestDB(t *testing.T) (*sql.DB, func()) { + config := DefaultTestDBConfig() + return SetupTestDBWithConfig(t, config) +} + +// SetupTestDBWithConfig allows custom configuration +func SetupTestDBWithConfig(t *testing.T, config *TestDBConfig) (*sql.DB, func()) { + // Build connection string + connStr := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", + config.User, config.Password, config.Host, config.Port, config.DBName) + + // Connect to database + db, err := sql.Open("postgres", connStr) + if err != nil { + t.Skip("Skipping test: PostgreSQL not available") + return nil, nil + } + + // Verify connection + if err := db.Ping(); err != nil { + db.Close() + t.Skipf("Skipping test: Cannot connect to PostgreSQL: %v", err) + return nil, nil + } + + // Run migrations + if err := runMigrations(db); err != nil { + db.Close() + t.Fatalf("Failed to run migrations: %v", err) + } + + // Cleanup function + cleanup := func() { + // Drop all tables (run down migrations) + if err := dropAllTables(db); err != nil { + t.Logf("Warning: Failed to cleanup database: %v", err) + } + db.Close() + } + + return db, cleanup +} + +// runMigrations runs all up migrations +func runMigrations(db *sql.DB) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return fmt.Errorf("failed to create postgres driver: %w", err) + } + + m, err := migrate.NewWithDatabaseInstance( + "file://../../migrations", // Relative path from test files + "postgres", driver) + if err != nil { + return fmt.Errorf("failed to create migrate instance: %w", err) + } + defer m.Close() + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("failed to run migrations: %w", err) + } + + return nil +} + +// dropAllTables runs all down migrations to clean up +func dropAllTables(db *sql.DB) error { + driver, err := postgres.WithInstance(db, &postgres.Config{}) + if err != nil { + return err + } + + m, err := migrate.NewWithDatabaseInstance( + "file://../../migrations", + "postgres", driver) + if err != nil { + return err + } + defer m.Close() + + if err := m.Down(); err != nil && err != migrate.ErrNoChange { + return err + } + + return nil +} + +// CreateTestProperty is a helper to create a test property for repository tests +func CreateTestProperty(t *testing.T, db *sql.DB) int { + var propertyID int + err := db.QueryRow(` + INSERT INTO properties (property_name, property_code, address, property_type) + VALUES ($1, $2, $3, $4) + RETURNING id`, + "Test Property", "TEST001", "123 Test Street", "Commercial").Scan(&propertyID) + if err != nil { + t.Fatalf("Failed to create test property: %v", err) + } + return propertyID +} diff --git a/src/backend/api/migrations/000001_initial_schema.up.sql b/src/backend/api/migrations/000001_initial_schema.up.sql index bcb9bbc..678e243 100644 --- a/src/backend/api/migrations/000001_initial_schema.up.sql +++ b/src/backend/api/migrations/000001_initial_schema.up.sql @@ -20,7 +20,7 @@ CREATE TABLE properties ( city VARCHAR(100), postal_code VARCHAR(20), property_type VARCHAR(50) NOT NULL CHECK (property_type IN ('Residential', 'Commercial', 'Mixed')), - total_buildings INTEGER DEFAULT 1, + total_buildings INTEGER DEFAULT 0, metadata JSONB, -- Property-specific attributes active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT (NOW() AT TIME ZONE 'UTC'), diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile index 2d0225a..867dd15 100644 --- a/src/frontend/Dockerfile +++ b/src/frontend/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app COPY package*.json ./ # Install dependencies -RUN npm install && \ +RUN npm ci && \ npm cache clean --force # Copy source code @@ -20,7 +20,7 @@ RUN npm run build -- --configuration=production FROM nginx:alpine # Copy built application -COPY --from=builder /app/dist/tenantly-frontend /usr/share/nginx/html +COPY --from=builder /app/dist/tenantly-frontend/browser /usr/share/nginx/html # Copy nginx configuration COPY nginx.conf /etc/nginx/nginx.conf diff --git a/src/frontend/lint_output.txt b/src/frontend/lint_output.txt deleted file mode 100644 index a045657..0000000 Binary files a/src/frontend/lint_output.txt and /dev/null differ diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 4f8fe86..cad8ae1 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -1719,9 +1719,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.0.tgz", + "integrity": "sha512-ryJnSmj4UhrGLZZPJ6PKVb4wNPAIkW6iyLy+0TRwazd3L1u0wzMe8RfqevAh2HbcSkoeLiSYnOVDOys4JSGYyg==", "dev": true, "license": "MIT", "optional": true, @@ -1731,9 +1731,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.0.tgz", + "integrity": "sha512-Z82FDl1ByxqPEPrAYYeTQVlx2FSHPe1qwX465c+96IRS3fTdSYRoJcRxg3g2fEG5I69z1dSEWQlNRRr0/677mg==", "dev": true, "license": "MIT", "optional": true, diff --git a/src/frontend/src/app/app.routes.ts b/src/frontend/src/app/app.routes.ts index ddf05ea..be6c52d 100644 --- a/src/frontend/src/app/app.routes.ts +++ b/src/frontend/src/app/app.routes.ts @@ -1,5 +1,13 @@ import { Routes } from '@angular/router'; import { AuthGuard } from './core/guards/auth.guard'; +import { provideState } from '@ngrx/store'; +import { provideEffects } from '@ngrx/effects'; +import { propertyReducer } from './features/properties/store/property.reducer'; +import { buildingReducer } from './features/properties/store/building.reducer'; +import { unitReducer } from './features/properties/store/unit.reducer'; +import { PropertyEffects } from './features/properties/store/property.effects'; +import { BuildingEffects } from './features/properties/store/building.effects'; +import { UnitEffects } from './features/properties/store/unit.effects'; export const routes: Routes = [ { @@ -23,6 +31,12 @@ export const routes: Routes = [ (m) => m.PropertyListComponent ), canActivate: [AuthGuard], + providers: [ + provideState('properties', propertyReducer), + provideState('buildings', buildingReducer), + provideState('units', unitReducer), + provideEffects([PropertyEffects, BuildingEffects, UnitEffects]), + ], }, { path: 'tenants', diff --git a/src/frontend/src/app/core/services/unit.service.ts b/src/frontend/src/app/core/services/unit.service.ts index 4c1815a..859dc9e 100644 --- a/src/frontend/src/app/core/services/unit.service.ts +++ b/src/frontend/src/app/core/services/unit.service.ts @@ -23,7 +23,7 @@ export class UnitService { building_id?: number; unit_type?: UnitType; active?: boolean; - }): Observable { + }): Observable { let httpParams = new HttpParams(); if (params?.property_id) { httpParams = httpParams.set('property_id', params.property_id.toString()); @@ -37,7 +37,7 @@ export class UnitService { if (params?.active !== undefined) { httpParams = httpParams.set('active', params.active.toString()); } - return this.http.get(this.apiUrl, { params: httpParams }); + return this.http.get(this.apiUrl, { params: httpParams }); } getUnitsByBuilding(buildingId: number): Observable { diff --git a/src/frontend/src/app/core/store/app.state.ts b/src/frontend/src/app/core/store/app.state.ts new file mode 100644 index 0000000..169bbed --- /dev/null +++ b/src/frontend/src/app/core/store/app.state.ts @@ -0,0 +1,6 @@ +import { AuthState } from '../../store/auth/auth.reducer'; + +export interface AppState { + auth: AuthState; + // Feature states will be added here as they are lazy loaded +} diff --git a/src/frontend/src/app/features/auth/login/login.html b/src/frontend/src/app/features/auth/login/login.html index a4ff449..6e83d2e 100644 --- a/src/frontend/src/app/features/auth/login/login.html +++ b/src/frontend/src/app/features/auth/login/login.html @@ -28,6 +28,7 @@ +
Demo Credentials: Username: demo | Password: demo123 diff --git a/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.html b/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.html new file mode 100644 index 0000000..9dc954c --- /dev/null +++ b/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.html @@ -0,0 +1,78 @@ +

+ {{ isEditMode ? 'edit' : 'add_business' }} + {{ dialogTitle }} +

+ + +
+ business + {{ propertyName }} +
+ +
+ + Building Name + + apartment + {{ getErrorMessage('building_name') }} + + + + Building Code + + tag + Unique identifier for this building + {{ getErrorMessage('building_code') }} + + + + Building Type + + {{ type }} + + category + {{ getErrorMessage('building_type') }} + + +
+ + Total Floors + + layers + Optional + {{ getErrorMessage('total_floors') }} + + + + Construction Year + + calendar_today + Optional + {{ getErrorMessage('construction_year') }} + +
+ +
+ + elevator + Has Elevator + +
+
+
+ + + + + diff --git a/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.scss b/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.scss new file mode 100644 index 0000000..1040e1f --- /dev/null +++ b/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.scss @@ -0,0 +1,115 @@ +:host { + display: block; +} + +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + padding: 24px 24px 16px; + + mat-icon { + color: var(--primary-color, #1976d2); + } +} + +mat-dialog-content { + padding: 0 24px 24px; + min-width: 500px; + max-width: 600px; +} + +.property-context { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 4px; + margin-bottom: 16px; + + mat-icon { + color: rgba(0, 0, 0, 0.54); + font-size: 20px; + width: 20px; + height: 20px; + } + + span { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + } +} + +.building-form { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 8px; +} + +.full-width { + width: 100%; +} + +.form-row { + display: flex; + gap: 16px; + + .half-width { + flex: 1; + } +} + +.checkbox-field { + mat-checkbox { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: rgba(0, 0, 0, 0.54); + } + } +} + +mat-form-field { + mat-icon[matPrefix] { + margin-right: 8px; + color: rgba(0, 0, 0, 0.54); + } +} + +mat-dialog-actions { + padding: 16px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + + button { + mat-icon { + margin-right: 4px; + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +// Responsive +@media (max-width: 600px) { + mat-dialog-content { + min-width: auto; + width: 100%; + } + + .form-row { + flex-direction: column; + + .half-width { + width: 100%; + } + } +} diff --git a/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.ts b/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.ts new file mode 100644 index 0000000..bcdc071 --- /dev/null +++ b/src/frontend/src/app/features/properties/building-form-dialog/building-form-dialog.ts @@ -0,0 +1,160 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { Building, BuildingType, Property } from '../../../core/models'; + +export interface BuildingFormDialogData { + building?: Building; + property: Property; + mode: 'create' | 'edit'; +} + +@Component({ + selector: 'app-building-form-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatIconModule, + MatCheckboxModule, + ], + templateUrl: './building-form-dialog.html', + styleUrls: ['./building-form-dialog.scss'], +}) +export class BuildingFormDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private dialogRef = inject(MatDialogRef); + public data = inject(MAT_DIALOG_DATA); + + buildingForm!: FormGroup; + buildingTypes: BuildingType[] = ['Residential', 'Commercial', 'Mixed']; + currentYear = new Date().getFullYear(); + + ngOnInit() { + this.initializeForm(); + } + + private initializeForm() { + const building = this.data.building; + + this.buildingForm = this.fb.group({ + building_name: [ + building?.building_name || '', + [Validators.required, Validators.minLength(3), Validators.maxLength(100)], + ], + building_code: [ + building?.building_code || this.generateBuildingCode(), + [Validators.required, Validators.pattern(/^[A-Z0-9-]+$/)], + ], + building_type: [building?.building_type || 'Residential', [Validators.required]], + total_floors: [building?.total_floors || null, [Validators.min(1), Validators.max(200)]], + has_elevator: [building?.has_elevator || false], + construction_year: [ + building?.construction_year || null, + [Validators.min(1800), Validators.max(this.currentYear + 5)], + ], + }); + + // Disable building_code in edit mode + if (this.data.mode === 'edit') { + this.buildingForm.get('building_code')?.disable(); + } + } + + private generateBuildingCode(): string { + const timestamp = Date.now().toString().slice(-6); + return `BLD-${timestamp}`; + } + + onSubmit() { + if (this.buildingForm.valid) { + const formValue = this.buildingForm.getRawValue(); + + // Remove building_code from updates (it's immutable) + if (this.data.mode === 'edit') { + const { building_code, ...updateData } = formValue; + this.dialogRef.close(updateData); + } else { + // Add property_id for creation + this.dialogRef.close({ + ...formValue, + property_id: this.data.property.id, + }); + } + } else { + // Mark all fields as touched to show validation errors + Object.keys(this.buildingForm.controls).forEach((key) => { + this.buildingForm.get(key)?.markAsTouched(); + }); + } + } + + onCancel() { + this.dialogRef.close(); + } + + getErrorMessage(fieldName: string): string { + const control = this.buildingForm.get(fieldName); + if (!control || !control.errors || !control.touched) { + return ''; + } + + if (control.errors['required']) { + return `${this.getFieldLabel(fieldName)} is required`; + } + if (control.errors['minlength']) { + return `Minimum length is ${control.errors['minlength'].requiredLength}`; + } + if (control.errors['maxlength']) { + return `Maximum length is ${control.errors['maxlength'].requiredLength}`; + } + if (control.errors['min']) { + return `Minimum value is ${control.errors['min'].min}`; + } + if (control.errors['max']) { + return `Maximum value is ${control.errors['max'].max}`; + } + if (control.errors['pattern']) { + if (fieldName === 'building_code') { + return 'Only uppercase letters, numbers, and hyphens allowed'; + } + } + return 'Invalid value'; + } + + private getFieldLabel(fieldName: string): string { + const labels: { [key: string]: string } = { + building_name: 'Building Name', + building_code: 'Building Code', + building_type: 'Building Type', + total_floors: 'Total Floors', + has_elevator: 'Has Elevator', + construction_year: 'Construction Year', + }; + return labels[fieldName] || fieldName; + } + + get isEditMode(): boolean { + return this.data.mode === 'edit'; + } + + get dialogTitle(): string { + return this.isEditMode ? 'Edit Building' : 'Add New Building'; + } + + get propertyName(): string { + return this.data.property.property_name; + } +} diff --git a/src/frontend/src/app/features/properties/property-card/property-card.html b/src/frontend/src/app/features/properties/property-card/property-card.html new file mode 100644 index 0000000..dba0a34 --- /dev/null +++ b/src/frontend/src/app/features/properties/property-card/property-card.html @@ -0,0 +1,124 @@ + + + business + {{ property.property_name }} + {{ property.property_code }} + + + +
+
+ location_on + {{ property.address }}@if (property.city) {, {{ property.city }}} +
+
+ + {{ property.property_type }} + + @if (property.total_buildings && property.total_buildings > 0) { + {{ property.total_buildings }} Building(s) + } +
+
+ + + @if (property.expanded) { +
+
+

Buildings

+ +
+ + @if (property.buildings) { @for (building of property.buildings; track building.id) { + +
+ apartment +
+ {{ building.building_name }} + {{ building.building_code }} +
+ + {{ building.expanded ? 'expand_less' : 'expand_more' }} + +
+ + + @if (building.expanded) { +
+
+

Units

+ +
+ + @if (building.units && building.units.length > 0) { +
+ @for (unit of building.units; track unit.id) { +
+ + {{ getUnitTypeIcon(unit.unit_type) }} + +
+ {{ unit.unit_number }} + {{ unit.unit_type }} + ৳{{ unit.monthly_rent }}/mo +
+
+ + +
+
+ } +
+ } @else { +
+ inbox +

No units yet. Click the + button above to add your first unit.

+
+ } +
+ } +
+ } } @else { +
+ + Loading buildings... +
+ } +
+ } +
+ + + + + + +
diff --git a/src/frontend/src/app/features/properties/property-card/property-card.scss b/src/frontend/src/app/features/properties/property-card/property-card.scss new file mode 100644 index 0000000..c028be0 --- /dev/null +++ b/src/frontend/src/app/features/properties/property-card/property-card.scss @@ -0,0 +1,278 @@ +.property-card { + mat-card-header { + margin-bottom: 16px; + + mat-icon[mat-card-avatar] { + width: 40px; + height: 40px; + font-size: 40px; + background-color: transparent; + color: var(--mdc-theme-primary, #1976d2); + } + } + + .property-info { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; + + .info-row { + display: flex; + align-items: center; + gap: 8px; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + color: rgba(0, 0, 0, 0.54); + } + + span { + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.87); + } + + .building-count { + margin-left: auto; + font-weight: 500; + } + } + } + + .buildings-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + + h3, + h4 { + font-size: 1rem; + font-weight: 500; + margin: 0; + color: rgba(0, 0, 0, 0.87); + } + + h4 { + font-size: 0.875rem; + } + + button { + transform: scale(0.8); + + &.small-fab { + transform: scale(0.7); + } + } + } + + .loading-buildings { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + justify-content: center; + + span { + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.6); + } + } + } + + .building-card { + margin-bottom: 12px; + background-color: rgba(0, 0, 0, 0.02); + + .building-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + + mat-icon { + color: rgba(0, 0, 0, 0.54); + } + + .building-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + + strong { + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.87); + } + + .building-code { + font-size: 0.75rem; + color: rgba(0, 0, 0, 0.6); + } + } + + .expand-icon { + color: rgba(0, 0, 0, 0.54); + } + } + + .units-section { + padding: 12px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + + .section-header { + margin-bottom: 8px; + } + + .units-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; + + .unit-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + background-color: white; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + transition: all 0.2s; + + &:hover { + border-color: var(--mdc-theme-primary, #1976d2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + .unit-icon { + font-size: 24px; + width: 24px; + height: 24px; + flex-shrink: 0; + + &.shop { + color: #ff9800; + } + + &.apartment { + color: #2196f3; + } + + &.office { + color: #4caf50; + } + + &.parking { + color: #9c27b0; + } + + &.storage { + color: #795548; + } + + &.other { + color: #607d8b; + } + } + + .unit-details { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; + + strong { + font-size: 0.875rem; + color: rgba(0, 0, 0, 0.87); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .unit-type { + font-size: 0.75rem; + color: rgba(0, 0, 0, 0.6); + } + + .unit-rent { + font-size: 0.75rem; + color: var(--mdc-theme-primary, #1976d2); + font-weight: 500; + } + } + + .unit-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + + button { + width: 32px; + height: 32px; + line-height: 32px; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } + } + } + } + + .empty-units { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + text-align: center; + color: rgba(0, 0, 0, 0.6); + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: rgba(0, 0, 0, 0.26); + margin-bottom: 12px; + } + + p { + margin: 0; + font-size: 0.875rem; + } + } + } + } + + mat-card-actions { + display: flex; + gap: 8px; + padding: 8px 16px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + + button { + mat-icon { + margin-right: 4px; + font-size: 18px; + width: 18px; + height: 18px; + } + } + } +} diff --git a/src/frontend/src/app/features/properties/property-card/property-card.spec.ts b/src/frontend/src/app/features/properties/property-card/property-card.spec.ts new file mode 100644 index 0000000..a43e47a --- /dev/null +++ b/src/frontend/src/app/features/properties/property-card/property-card.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { PropertyCard } from './property-card'; + +describe('PropertyCard', () => { + let component: PropertyCard; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PropertyCard], + }).compileComponents(); + + fixture = TestBed.createComponent(PropertyCard); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/src/app/features/properties/property-card/property-card.ts b/src/frontend/src/app/features/properties/property-card/property-card.ts new file mode 100644 index 0000000..affbae6 --- /dev/null +++ b/src/frontend/src/app/features/properties/property-card/property-card.ts @@ -0,0 +1,116 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { Property, Building, Unit, UnitType } from '../../../core/models'; + +export interface DisplayedProperty extends Property { + expanded: boolean; + buildings?: BuildingWithUnits[]; +} + +export interface BuildingWithUnits extends Building { + expanded: boolean; + units?: Unit[]; +} + +@Component({ + selector: 'app-property-card', + standalone: true, + imports: [ + CommonModule, + MatCardModule, + MatIconModule, + MatButtonModule, + MatChipsModule, + MatTooltipModule, + MatProgressSpinnerModule, + ], + templateUrl: './property-card.html', + styleUrls: ['./property-card.scss'], +}) +export class PropertyCardComponent { + @Input() property!: DisplayedProperty; + + @Output() toggleProperty = new EventEmitter(); + @Output() editProperty = new EventEmitter(); + @Output() deleteProperty = new EventEmitter(); + @Output() addBuilding = new EventEmitter(); + @Output() toggleBuilding = new EventEmitter(); + @Output() addUnit = new EventEmitter<{ + building: BuildingWithUnits; + property: DisplayedProperty; + }>(); + @Output() editUnit = new EventEmitter<{ + unit: Unit; + building: BuildingWithUnits; + property: DisplayedProperty; + }>(); + @Output() deleteUnit = new EventEmitter<{ unit: Unit; building: BuildingWithUnits }>(); + + onToggleProperty() { + this.toggleProperty.emit(this.property); + } + + onEditProperty() { + this.editProperty.emit(this.property); + } + + onDeleteProperty() { + this.deleteProperty.emit(this.property); + } + + onAddBuilding() { + this.addBuilding.emit(this.property); + } + + onToggleBuilding(building: BuildingWithUnits) { + this.toggleBuilding.emit(building); + } + + onAddUnit(building: BuildingWithUnits) { + this.addUnit.emit({ building, property: this.property }); + } + + onEditUnit(unit: Unit, building: BuildingWithUnits) { + this.editUnit.emit({ unit, building, property: this.property }); + } + + onDeleteUnit(unit: Unit, building: BuildingWithUnits) { + this.deleteUnit.emit({ unit, building }); + } + + getPropertyTypeColor(type: string): string { + switch (type) { + case 'Residential': + return 'primary'; + case 'Commercial': + return 'accent'; + case 'Mixed': + return 'warn'; + default: + return ''; + } + } + + getUnitTypeIcon(type: UnitType): string { + switch (type) { + case 'Shop': + return 'store'; + case 'Apartment': + return 'home'; + case 'Office': + return 'business'; + case 'Parking': + return 'local_parking'; + case 'Storage': + return 'inventory_2'; + default: + return 'meeting_room'; + } + } +} diff --git a/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.html b/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.html new file mode 100644 index 0000000..00ce449 --- /dev/null +++ b/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.html @@ -0,0 +1,72 @@ +

+ {{ isEditMode ? 'edit' : 'add_business' }} + {{ dialogTitle }} +

+ + +
+ + Property Name + + business + {{ getErrorMessage('property_name') }} + + + + Property Code + + tag + Unique identifier for this property + {{ getErrorMessage('property_code') }} + + + + Property Type + + {{ type }} + + category + {{ getErrorMessage('property_type') }} + + + + Address + + location_on + {{ getErrorMessage('address') }} + + +
+ + City + + location_city + {{ getErrorMessage('city') }} + + + + Postal Code + + markunread_mailbox + 4-digit code + {{ getErrorMessage('postal_code') }} + +
+
+
+ + + + + diff --git a/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.scss b/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.scss new file mode 100644 index 0000000..4b4ec6c --- /dev/null +++ b/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.scss @@ -0,0 +1,78 @@ +:host { + display: block; +} + +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + padding: 24px 24px 16px; + + mat-icon { + color: var(--primary-color, #1976d2); + } +} + +mat-dialog-content { + padding: 0 24px 24px; + min-width: 500px; + max-width: 600px; +} + +.property-form { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 8px; +} + +.full-width { + width: 100%; +} + +.form-row { + display: flex; + gap: 16px; + + .half-width { + flex: 1; + } +} + +mat-form-field { + mat-icon[matPrefix] { + margin-right: 8px; + color: rgba(0, 0, 0, 0.54); + } +} + +mat-dialog-actions { + padding: 16px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + + button { + mat-icon { + margin-right: 4px; + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +// Responsive +@media (max-width: 600px) { + mat-dialog-content { + min-width: auto; + width: 100%; + } + + .form-row { + flex-direction: column; + + .half-width { + width: 100%; + } + } +} diff --git a/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.ts b/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.ts new file mode 100644 index 0000000..4694434 --- /dev/null +++ b/src/frontend/src/app/features/properties/property-form-dialog/property-form-dialog.ts @@ -0,0 +1,142 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { Property, PropertyType } from '../../../core/models'; + +export interface PropertyFormDialogData { + property?: Property; + mode: 'create' | 'edit'; +} + +@Component({ + selector: 'app-property-form-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatIconModule, + ], + templateUrl: './property-form-dialog.html', + styleUrls: ['./property-form-dialog.scss'], +}) +export class PropertyFormDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private dialogRef = inject(MatDialogRef); + public data = inject(MAT_DIALOG_DATA); + + propertyForm!: FormGroup; + propertyTypes: PropertyType[] = ['Residential', 'Commercial', 'Mixed']; + + ngOnInit() { + this.initializeForm(); + } + + private initializeForm() { + const property = this.data.property; + + this.propertyForm = this.fb.group({ + property_name: [ + property?.property_name || '', + [Validators.required, Validators.minLength(3), Validators.maxLength(100)], + ], + property_code: [ + property?.property_code || this.generatePropertyCode(), + [Validators.required, Validators.pattern(/^[A-Z0-9-]+$/)], + ], + address: [property?.address || '', [Validators.required, Validators.minLength(5)]], + city: [property?.city || '', [Validators.maxLength(50)]], + postal_code: [property?.postal_code || '', [Validators.pattern(/^\d{4}$/)]], + property_type: [property?.property_type || 'Residential', [Validators.required]], + }); + + // Disable property_code in edit mode + if (this.data.mode === 'edit') { + this.propertyForm.get('property_code')?.disable(); + } + } + + private generatePropertyCode(): string { + const timestamp = Date.now().toString().slice(-6); + return `PROP-${timestamp}`; + } + + onSubmit() { + if (this.propertyForm.valid) { + const formValue = this.propertyForm.getRawValue(); + + // Remove property_code from updates (it's immutable) + if (this.data.mode === 'edit') { + const { property_code, ...updateData } = formValue; + this.dialogRef.close(updateData); + } else { + this.dialogRef.close(formValue); + } + } else { + // Mark all fields as touched to show validation errors + Object.keys(this.propertyForm.controls).forEach((key) => { + this.propertyForm.get(key)?.markAsTouched(); + }); + } + } + + onCancel() { + this.dialogRef.close(); + } + + getErrorMessage(fieldName: string): string { + const control = this.propertyForm.get(fieldName); + if (!control || !control.errors || !control.touched) { + return ''; + } + + if (control.errors['required']) { + return `${this.getFieldLabel(fieldName)} is required`; + } + if (control.errors['minlength']) { + return `Minimum length is ${control.errors['minlength'].requiredLength}`; + } + if (control.errors['maxlength']) { + return `Maximum length is ${control.errors['maxlength'].requiredLength}`; + } + if (control.errors['pattern']) { + if (fieldName === 'property_code') { + return 'Only uppercase letters, numbers, and hyphens allowed'; + } + if (fieldName === 'postal_code') { + return 'Must be a 4-digit postal code'; + } + } + return 'Invalid value'; + } + + private getFieldLabel(fieldName: string): string { + const labels: { [key: string]: string } = { + property_name: 'Property Name', + property_code: 'Property Code', + address: 'Address', + city: 'City', + postal_code: 'Postal Code', + property_type: 'Property Type', + }; + return labels[fieldName] || fieldName; + } + + get isEditMode(): boolean { + return this.data.mode === 'edit'; + } + + get dialogTitle(): string { + return this.isEditMode ? 'Edit Property' : 'Add New Property'; + } +} diff --git a/src/frontend/src/app/features/properties/property-list/property-list.component.html b/src/frontend/src/app/features/properties/property-list/property-list.component.html index d34ceb0..2afded9 100644 --- a/src/frontend/src/app/features/properties/property-list/property-list.component.html +++ b/src/frontend/src/app/features/properties/property-list/property-list.component.html @@ -9,130 +9,35 @@

Properties

@if (loading()) {
- +

Loading properties...

} @if (error()) { - error -

{{ error() }}

- + + error +

{{ error() }}

+ +
} @if (!loading() && !error()) {
- @for (property of properties(); track property.id) { - - - business - {{ property.property_name }} - {{ property.property_code }} - - - -
-
- location_on - {{ property.address }}, {{ property.city }} -
-
- - {{ property.property_type }} - - {{ property.total_buildings }} Building(s) -
-
- - - @if (property.expanded) { -
-
-

Buildings

- -
- - @if (property.buildings) { - @for (building of property.buildings; track building.id) { - -
- apartment -
- {{ building.building_name }} - {{ building.building_code }} -
- - {{ building.expanded ? 'expand_less' : 'expand_more' }} - -
- - - @if (building.expanded && building.units) { -
-
-

Units

- -
-
- @for (unit of building.units; track unit.id) { -
- - {{ getUnitTypeIcon(unit.unit_type) }} - -
- {{ unit.unit_number }} - {{ unit.unit_type }} - ৳{{ unit.monthly_rent }}/mo -
-
- } -
-
- } -
- } - } @else { -
- - Loading buildings... -
- } -
- } -
- - - - - - -
+ @for (property of displayedProperties(); track property.id) { + }
diff --git a/src/frontend/src/app/features/properties/property-list/property-list.component.scss b/src/frontend/src/app/features/properties/property-list/property-list.component.scss index b30b722..373e104 100644 --- a/src/frontend/src/app/features/properties/property-list/property-list.component.scss +++ b/src/frontend/src/app/features/properties/property-list/property-list.component.scss @@ -1,5 +1,9 @@ -.property-list-container { +:host { + display: block; padding: 24px; +} + +.property-list-container { max-width: 1400px; margin: 0 auto; } @@ -13,7 +17,8 @@ h1 { margin: 0; font-size: 2rem; - font-weight: 500; + font-weight: 400; + color: rgba(0, 0, 0, 0.87); } button { @@ -25,24 +30,15 @@ .loading-container { display: flex; - flex-direction: column; - align-items: center; justify-content: center; - padding: 48px; - gap: 16px; - - p { - color: rgba(0, 0, 0, 0.6); - } + align-items: center; + min-height: 400px; } -.error-card { - display: flex; - flex-direction: column; - align-items: center; +.error-container { padding: 32px; text-align: center; - gap: 16px; + color: #f44336; mat-icon { font-size: 48px; diff --git a/src/frontend/src/app/features/properties/property-list/property-list.component.ts b/src/frontend/src/app/features/properties/property-list/property-list.component.ts index 2ee63fe..52bf975 100644 --- a/src/frontend/src/app/features/properties/property-list/property-list.component.ts +++ b/src/frontend/src/app/features/properties/property-list/property-list.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { RouterModule } from '@angular/router'; import { MatCardModule } from '@angular/material/card'; @@ -8,17 +8,29 @@ import { MatChipsModule } from '@angular/material/chips'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatExpansionModule } from '@angular/material/expansion'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { PropertyService } from '../../../core/services/property.service'; +import { MatDialog } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { PropertyActions } from '../store/property.actions'; +import { BuildingActions } from '../store/building.actions'; +import { UnitActions } from '../store/unit.actions'; +import { + selectAllProperties, + selectPropertyLoading, + selectPropertyError, +} from '../store/property.selectors'; import { BuildingService } from '../../../core/services/building.service'; import { UnitService } from '../../../core/services/unit.service'; import { Property, Building, Unit, - PropertyListResponse, BuildingListResponse, UnitListResponse, } from '../../../core/models'; +import { DisplayedProperty, PropertyCardComponent } from '../property-card/property-card'; +import { PropertyFormDialogComponent } from '../property-form-dialog/property-form-dialog'; +import { BuildingFormDialogComponent } from '../building-form-dialog/building-form-dialog'; +import { UnitFormDialogComponent } from '../unit-form-dialog/unit-form-dialog'; interface PropertyWithHierarchy extends Property { buildings?: BuildingWithUnits[]; @@ -42,52 +54,68 @@ interface BuildingWithUnits extends Building { MatProgressSpinnerModule, MatExpansionModule, MatTooltipModule, + PropertyCardComponent, ], templateUrl: './property-list.component.html', styleUrls: ['./property-list.component.scss'], }) export class PropertyListComponent implements OnInit { - private propertyService = inject(PropertyService); + private store = inject(Store); private buildingService = inject(BuildingService); private unitService = inject(UnitService); + private dialog = inject(MatDialog); - properties = signal([]); - loading = signal(false); - error = signal(null); + // Store selectors + properties = this.store.selectSignal(selectAllProperties); + loading = this.store.selectSignal(selectPropertyLoading); + error = this.store.selectSignal(selectPropertyError); - ngOnInit() { - this.loadProperties(); - } + // UI state + expandedProperties = signal>(new Set()); + loadedBuildings = signal>(new Map()); - loadProperties() { - this.loading.set(true); - this.error.set(null); + // Combined state for template + displayedProperties = computed(() => { + const props = this.properties(); + const expandedProps = this.expandedProperties(); + const buildingsMap = this.loadedBuildings(); - this.propertyService.getProperties({ active: true }).subscribe({ - next: (response: PropertyListResponse) => { - this.properties.set(response.properties.map((p: Property) => ({ ...p, expanded: false }))); - this.loading.set(false); - }, - error: (err: any) => { - this.error.set('Failed to load properties'); - this.loading.set(false); - console.error('Error loading properties:', err); - }, - }); + return props.map((p) => ({ + ...p, + expanded: expandedProps.has(p.id), + buildings: buildingsMap.get(p.id), + })) as DisplayedProperty[]; + }); + + ngOnInit() { + this.store.dispatch(PropertyActions.loadProperties({ active: true })); } toggleProperty(property: PropertyWithHierarchy) { - property.expanded = !property.expanded; + this.expandedProperties.update((expanded) => { + const newExpanded = new Set(expanded); + if (newExpanded.has(property.id)) { + newExpanded.delete(property.id); + } else { + newExpanded.add(property.id); + } + return newExpanded; + }); - if (property.expanded && !property.buildings) { - this.loadBuildings(property); + if (!property.buildings) { + this.loadBuildings(property.id); } } - loadBuildings(property: PropertyWithHierarchy) { - this.buildingService.getBuildingsByProperty(property.id).subscribe({ + loadBuildings(propertyId: number) { + this.buildingService.getBuildingsByProperty(propertyId).subscribe({ next: (response: BuildingListResponse) => { - property.buildings = response.buildings.map((b: Building) => ({ ...b, expanded: false })); + const buildings = response.buildings.map((b: Building) => ({ ...b, expanded: false })); + this.loadedBuildings.update((map) => { + const newMap = new Map(map); + newMap.set(propertyId, buildings); + return newMap; + }); }, error: (err: any) => { console.error('Error loading buildings:', err); @@ -144,36 +172,127 @@ export class PropertyListComponent implements OnInit { } } - // Add handlers + // Property CRUD handlers addProperty() { - console.log('Add Property clicked'); - // TODO: Open dialog to add property - alert('Add Property form will be implemented here'); + const dialogRef = this.dialog.open(PropertyFormDialogComponent, { + width: '600px', + data: { mode: 'create' }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.store.dispatch(PropertyActions.createProperty({ property: result })); + } + }); + } + + editProperty(property: DisplayedProperty) { + const dialogRef = this.dialog.open(PropertyFormDialogComponent, { + width: '600px', + data: { mode: 'edit', property }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.store.dispatch( + PropertyActions.updateProperty({ + id: property.id, + property: result, + }) + ); + } + }); + } + + deleteProperty(property: DisplayedProperty) { + if (confirm(`Are you sure you want to delete ${property.property_name}?`)) { + this.store.dispatch(PropertyActions.deleteProperty({ id: property.id })); + } } - addBuilding(property: PropertyWithHierarchy) { - console.log('Add Building to property:', property.property_name); - // TODO: Open dialog to add building - alert(`Add Building to ${property.property_name} will be implemented here`); + // Building CRUD handlers + addBuilding(property: DisplayedProperty) { + const dialogRef = this.dialog.open(BuildingFormDialogComponent, { + width: '600px', + data: { mode: 'create', property }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.store.dispatch(BuildingActions.createBuilding({ request: result })); + // Reload buildings after creation + setTimeout(() => { + this.loadBuildings(property.id); + }, 500); + } + }); } - addUnit(building: BuildingWithUnits, property: PropertyWithHierarchy) { - console.log('Add Unit to building:', building.building_name); - // TODO: Open dialog to add unit - alert(`Add Unit to ${building.building_name} will be implemented here`); + editBuilding(building: BuildingWithUnits, property: DisplayedProperty) { + const dialogRef = this.dialog.open(BuildingFormDialogComponent, { + width: '600px', + data: { mode: 'edit', building, property }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.store.dispatch( + BuildingActions.updateBuilding({ + id: building.id, + request: result, + }) + ); + } + }); } - editProperty(property: PropertyWithHierarchy) { - console.log('Edit property:', property.property_name); - // TODO: Open dialog to edit property - alert(`Edit ${property.property_name} will be implemented here`); + deleteBuilding(building: BuildingWithUnits, propertyId: number) { + if (confirm(`Are you sure you want to delete ${building.building_name}?`)) { + this.store.dispatch(BuildingActions.deleteBuilding({ id: building.id })); + // Reload buildings for this property after deletion + setTimeout(() => this.loadBuildings(propertyId), 500); + } } - deleteProperty(property: PropertyWithHierarchy) { - console.log('Delete property:', property.property_name); - // TODO: Confirm and delete property - if (confirm(`Are you sure you want to delete ${property.property_name}?`)) { - alert('Delete functionality will be implemented here'); + // Unit CRUD handlers + addUnit(building: BuildingWithUnits, property: DisplayedProperty) { + const dialogRef = this.dialog.open(UnitFormDialogComponent, { + width: '600px', + data: { mode: 'create', building, property }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.store.dispatch(UnitActions.createUnit({ request: result })); + // Reload units for this building after creation + setTimeout(() => this.loadUnits(building), 500); + } + }); + } + + editUnit(unit: Unit, building: BuildingWithUnits, property: DisplayedProperty) { + const dialogRef = this.dialog.open(UnitFormDialogComponent, { + width: '600px', + data: { mode: 'edit', unit, building, property }, + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.store.dispatch( + UnitActions.updateUnit({ + id: unit.id, + request: result, + }) + ); + } + }); + } + + deleteUnit(unit: Unit, building: BuildingWithUnits) { + if (confirm(`Are you sure you want to delete unit ${unit.unit_number}?`)) { + this.store.dispatch(UnitActions.deleteUnit({ id: unit.id })); + // Reload units for this building after deletion + setTimeout(() => this.loadUnits(building), 500); } } } diff --git a/src/frontend/src/app/features/properties/property.routes.ts b/src/frontend/src/app/features/properties/property.routes.ts new file mode 100644 index 0000000..e7679bd --- /dev/null +++ b/src/frontend/src/app/features/properties/property.routes.ts @@ -0,0 +1,24 @@ +import { Routes } from '@angular/router'; +import { provideState } from '@ngrx/store'; +import { provideEffects } from '@ngrx/effects'; +import { propertyReducer } from './store/property.reducer'; +import { PropertyEffects } from './store/property.effects'; +import { buildingReducer } from './store/building.reducer'; +import { BuildingEffects } from './store/building.effects'; +import { unitReducer } from './store/unit.reducer'; +import { UnitEffects } from './store/unit.effects'; +import { PropertyListComponent } from './property-list/property-list.component'; + +export const PROPERTY_ROUTES: Routes = [ + { + path: '', + component: PropertyListComponent, + providers: [ + provideState('properties', propertyReducer), + provideState('buildings', buildingReducer), + provideState('units', unitReducer), + provideEffects(PropertyEffects, BuildingEffects, UnitEffects), + ], + }, + // Child routes for details, buildings, etc. will be added here +]; diff --git a/src/frontend/src/app/features/properties/store/building.actions.ts b/src/frontend/src/app/features/properties/store/building.actions.ts new file mode 100644 index 0000000..a6d1466 --- /dev/null +++ b/src/frontend/src/app/features/properties/store/building.actions.ts @@ -0,0 +1,33 @@ +import { createActionGroup, emptyProps, props } from '@ngrx/store'; +import { + Building, + CreateBuildingRequest, + UpdateBuildingRequest, +} from '../../../core/models/building.model'; + +export const BuildingActions = createActionGroup({ + source: 'Building', + events: { + 'Load Buildings': props<{ propertyId?: number; active?: boolean }>(), + 'Load Buildings Success': props<{ buildings: Building[] }>(), + 'Load Buildings Failure': props<{ error: any }>(), + + 'Load Building': props<{ id: number }>(), + 'Load Building Success': props<{ building: Building }>(), + 'Load Building Failure': props<{ error: any }>(), + + 'Create Building': props<{ request: CreateBuildingRequest }>(), + 'Create Building Success': props<{ building: Building }>(), + 'Create Building Failure': props<{ error: any }>(), + + 'Update Building': props<{ id: number; request: UpdateBuildingRequest }>(), + 'Update Building Success': props<{ building: Building }>(), + 'Update Building Failure': props<{ error: any }>(), + + 'Delete Building': props<{ id: number }>(), + 'Delete Building Success': props<{ id: number }>(), + 'Delete Building Failure': props<{ error: any }>(), + + 'Select Building': props<{ id: number | null }>(), + }, +}); diff --git a/src/frontend/src/app/features/properties/store/building.effects.ts b/src/frontend/src/app/features/properties/store/building.effects.ts new file mode 100644 index 0000000..c485f91 --- /dev/null +++ b/src/frontend/src/app/features/properties/store/building.effects.ts @@ -0,0 +1,104 @@ +import { Injectable, inject } from '@angular/core'; // Wait, inject is from @angular/core +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { of } from 'rxjs'; +import { map, mergeMap, catchError, tap } from 'rxjs/operators'; +import { BuildingService } from '../../../core/services/building.service'; +import { BuildingActions } from './building.actions'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Building, BuildingListResponse } from '../../../core/models'; + +@Injectable() +export class BuildingEffects { + private actions$ = inject(Actions); + private buildingService = inject(BuildingService); + private snackBar = inject(MatSnackBar); + + loadBuildings$ = createEffect(() => + this.actions$.pipe( + ofType(BuildingActions.loadBuildings), + mergeMap(({ propertyId, active }) => { + if (propertyId) { + return this.buildingService.getBuildingsByProperty(propertyId).pipe( + map((response: BuildingListResponse) => + BuildingActions.loadBuildingsSuccess({ buildings: response.buildings }) + ), + catchError((error) => of(BuildingActions.loadBuildingsFailure({ error }))) + ); + } else { + return this.buildingService.getBuildings({ active }).pipe( + map((response: BuildingListResponse) => + BuildingActions.loadBuildingsSuccess({ buildings: response.buildings }) + ), + catchError((error) => of(BuildingActions.loadBuildingsFailure({ error }))) + ); + } + }) + ) + ); + + loadBuilding$ = createEffect(() => + this.actions$.pipe( + ofType(BuildingActions.loadBuilding), + mergeMap(({ id }) => + this.buildingService.getBuilding(id).pipe( + map((building: Building) => BuildingActions.loadBuildingSuccess({ building })), + catchError((error) => of(BuildingActions.loadBuildingFailure({ error }))) + ) + ) + ) + ); + + createBuilding$ = createEffect(() => + this.actions$.pipe( + ofType(BuildingActions.createBuilding), + mergeMap(({ request }) => + this.buildingService.createBuilding(request).pipe( + map((building: Building) => { + this.snackBar.open('Building created successfully', 'Close', { duration: 3000 }); + return BuildingActions.createBuildingSuccess({ building }); + }), + catchError((error) => { + this.snackBar.open('Error creating building', 'Close', { duration: 3000 }); + return of(BuildingActions.createBuildingFailure({ error })); + }) + ) + ) + ) + ); + + updateBuilding$ = createEffect(() => + this.actions$.pipe( + ofType(BuildingActions.updateBuilding), + mergeMap(({ id, request }) => + this.buildingService.updateBuilding(id, request).pipe( + map((building: Building) => { + this.snackBar.open('Building updated successfully', 'Close', { duration: 3000 }); + return BuildingActions.updateBuildingSuccess({ building }); + }), + catchError((error) => { + this.snackBar.open('Error updating building', 'Close', { duration: 3000 }); + return of(BuildingActions.updateBuildingFailure({ error })); + }) + ) + ) + ) + ); + + deleteBuilding$ = createEffect(() => + this.actions$.pipe( + ofType(BuildingActions.deleteBuilding), + mergeMap(({ id }) => + this.buildingService.deleteBuilding(id).pipe( + map(() => { + this.snackBar.open('Building deleted successfully', 'Close', { duration: 3000 }); + return BuildingActions.deleteBuildingSuccess({ id }); + }), + catchError((error) => { + this.snackBar.open('Error deleting building', 'Close', { duration: 3000 }); + return of(BuildingActions.deleteBuildingFailure({ error })); + }) + ) + ) + ) + ); +} diff --git a/src/frontend/src/app/features/properties/store/building.reducer.ts b/src/frontend/src/app/features/properties/store/building.reducer.ts new file mode 100644 index 0000000..05f6129 --- /dev/null +++ b/src/frontend/src/app/features/properties/store/building.reducer.ts @@ -0,0 +1,103 @@ +import { createReducer, on } from '@ngrx/store'; +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { Building } from '../../../core/models/building.model'; +import { BuildingActions } from './building.actions'; + +export interface BuildingState extends EntityState { + selectedId: number | null; + loading: boolean; + error: any; +} + +export const buildingAdapter: EntityAdapter = createEntityAdapter(); + +export const initialBuildingState: BuildingState = buildingAdapter.getInitialState({ + selectedId: null, + loading: false, + error: null, +}); + +export const buildingReducer = createReducer( + initialBuildingState, + + // Load Buildings + on(BuildingActions.loadBuildings, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(BuildingActions.loadBuildingsSuccess, (state, { buildings }) => + buildingAdapter.setAll(buildings, { ...state, loading: false }) + ), + on(BuildingActions.loadBuildingsFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Load Building + on(BuildingActions.loadBuilding, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(BuildingActions.loadBuildingSuccess, (state, { building }) => + buildingAdapter.upsertOne(building, { ...state, loading: false, selectedId: building.id }) + ), + on(BuildingActions.loadBuildingFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Create Building + on(BuildingActions.createBuilding, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(BuildingActions.createBuildingSuccess, (state, { building }) => + buildingAdapter.addOne(building, { ...state, loading: false }) + ), + on(BuildingActions.createBuildingFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Update Building + on(BuildingActions.updateBuilding, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(BuildingActions.updateBuildingSuccess, (state, { building }) => + buildingAdapter.updateOne({ id: building.id, changes: building }, { ...state, loading: false }) + ), + on(BuildingActions.updateBuildingFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Delete Building + on(BuildingActions.deleteBuilding, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(BuildingActions.deleteBuildingSuccess, (state, { id }) => + buildingAdapter.removeOne(id, { ...state, loading: false }) + ), + on(BuildingActions.deleteBuildingFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Select Building + on(BuildingActions.selectBuilding, (state, { id }) => ({ + ...state, + selectedId: id, + })) +); diff --git a/src/frontend/src/app/features/properties/store/building.selectors.ts b/src/frontend/src/app/features/properties/store/building.selectors.ts new file mode 100644 index 0000000..d198c65 --- /dev/null +++ b/src/frontend/src/app/features/properties/store/building.selectors.ts @@ -0,0 +1,25 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { BuildingState, buildingAdapter } from './building.reducer'; + +export const selectBuildingState = createFeatureSelector('buildings'); + +const { selectAll, selectEntities, selectIds, selectTotal } = buildingAdapter.getSelectors(); + +export const selectAllBuildings = createSelector(selectBuildingState, selectAll); + +export const selectBuildingEntities = createSelector(selectBuildingState, selectEntities); + +export const selectBuildingLoading = createSelector(selectBuildingState, (state) => state.loading); + +export const selectBuildingError = createSelector(selectBuildingState, (state) => state.error); + +export const selectSelectedBuildingId = createSelector( + selectBuildingState, + (state) => state.selectedId +); + +export const selectSelectedBuilding = createSelector( + selectBuildingEntities, + selectSelectedBuildingId, + (entities, selectedId) => (selectedId ? entities[selectedId] : null) +); diff --git a/src/frontend/src/app/features/properties/store/property.actions.ts b/src/frontend/src/app/features/properties/store/property.actions.ts new file mode 100644 index 0000000..7fe25dc --- /dev/null +++ b/src/frontend/src/app/features/properties/store/property.actions.ts @@ -0,0 +1,30 @@ +import { createActionGroup, emptyProps, props } from '@ngrx/store'; +import { Property, CreatePropertyRequest, UpdatePropertyRequest } from '../../../core/models'; + +export const PropertyActions = createActionGroup({ + source: 'Property', + events: { + 'Load Properties': props<{ active?: boolean }>(), + 'Load Properties Success': props<{ properties: Property[] }>(), + 'Load Properties Failure': props<{ error: string }>(), + + 'Load Property': props<{ id: number }>(), + 'Load Property Success': props<{ property: Property }>(), + 'Load Property Failure': props<{ error: string }>(), + + 'Create Property': props<{ property: CreatePropertyRequest }>(), + 'Create Property Success': props<{ property: Property }>(), + 'Create Property Failure': props<{ error: string }>(), + + 'Update Property': props<{ id: number; property: UpdatePropertyRequest }>(), + 'Update Property Success': props<{ property: Property }>(), + 'Update Property Failure': props<{ error: string }>(), + + 'Delete Property': props<{ id: number }>(), + 'Delete Property Success': props<{ id: number }>(), + 'Delete Property Failure': props<{ error: string }>(), + + 'Select Property': props<{ id: number }>(), + 'Clear Selected Property': emptyProps(), + }, +}); diff --git a/src/frontend/src/app/features/properties/store/property.effects.ts b/src/frontend/src/app/features/properties/store/property.effects.ts new file mode 100644 index 0000000..48a9bb0 --- /dev/null +++ b/src/frontend/src/app/features/properties/store/property.effects.ts @@ -0,0 +1,94 @@ +import { Injectable, inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { of } from 'rxjs'; +import { map, mergeMap, catchError, tap } from 'rxjs/operators'; +import { PropertyService } from '../../../core/services/property.service'; +import { PropertyActions } from './property.actions'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +@Injectable() +export class PropertyEffects { + private actions$ = inject(Actions); + private propertyService = inject(PropertyService); + private snackBar = inject(MatSnackBar); + + loadProperties$ = createEffect(() => + this.actions$.pipe( + ofType(PropertyActions.loadProperties), + mergeMap(({ active }) => + this.propertyService.getProperties({ active }).pipe( + map((response) => + PropertyActions.loadPropertiesSuccess({ properties: response.properties }) + ), + catchError((error) => of(PropertyActions.loadPropertiesFailure({ error: error.message }))) + ) + ) + ) + ); + + loadProperty$ = createEffect(() => + this.actions$.pipe( + ofType(PropertyActions.loadProperty), + mergeMap(({ id }) => + this.propertyService.getProperty(id).pipe( + map((property) => PropertyActions.loadPropertySuccess({ property })), + catchError((error) => of(PropertyActions.loadPropertyFailure({ error: error.message }))) + ) + ) + ) + ); + + createProperty$ = createEffect(() => + this.actions$.pipe( + ofType(PropertyActions.createProperty), + mergeMap(({ property }) => + this.propertyService.createProperty(property).pipe( + map((newProperty) => { + this.snackBar.open('Property created successfully', 'Close', { duration: 3000 }); + return PropertyActions.createPropertySuccess({ property: newProperty }); + }), + catchError((error) => { + this.snackBar.open('Failed to create property', 'Close', { duration: 3000 }); + return of(PropertyActions.createPropertyFailure({ error: error.message })); + }) + ) + ) + ) + ); + + updateProperty$ = createEffect(() => + this.actions$.pipe( + ofType(PropertyActions.updateProperty), + mergeMap(({ id, property }) => + this.propertyService.updateProperty(id, property).pipe( + map((updatedProperty) => { + this.snackBar.open('Property updated successfully', 'Close', { duration: 3000 }); + return PropertyActions.updatePropertySuccess({ property: updatedProperty }); + }), + catchError((error) => { + this.snackBar.open('Failed to update property', 'Close', { duration: 3000 }); + return of(PropertyActions.updatePropertyFailure({ error: error.message })); + }) + ) + ) + ) + ); + + deleteProperty$ = createEffect(() => + this.actions$.pipe( + ofType(PropertyActions.deleteProperty), + mergeMap(({ id }) => + this.propertyService.deleteProperty(id).pipe( + map(() => { + this.snackBar.open('Property deleted successfully', 'Close', { duration: 3000 }); + return PropertyActions.deletePropertySuccess({ id }); + }), + catchError((error) => { + this.snackBar.open('Failed to delete property', 'Close', { duration: 3000 }); + return of(PropertyActions.deletePropertyFailure({ error: error.message })); + }) + ) + ) + ) + ); +} diff --git a/src/frontend/src/app/features/properties/store/property.reducer.ts b/src/frontend/src/app/features/properties/store/property.reducer.ts new file mode 100644 index 0000000..19aedeb --- /dev/null +++ b/src/frontend/src/app/features/properties/store/property.reducer.ts @@ -0,0 +1,108 @@ +import { createReducer, on } from '@ngrx/store'; +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { Property } from '../../../core/models'; +import { PropertyActions } from './property.actions'; + +export interface PropertyState extends EntityState { + selectedId: number | null; + loading: boolean; + error: string | null; +} + +export const adapter: EntityAdapter = createEntityAdapter(); + +export const initialState: PropertyState = adapter.getInitialState({ + selectedId: null, + loading: false, + error: null, +}); + +export const propertyReducer = createReducer( + initialState, + // Load Properties + on(PropertyActions.loadProperties, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(PropertyActions.loadPropertiesSuccess, (state, { properties }) => + adapter.setAll(properties, { ...state, loading: false }) + ), + on(PropertyActions.loadPropertiesFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Load Property + on(PropertyActions.loadProperty, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(PropertyActions.loadPropertySuccess, (state, { property }) => + adapter.upsertOne(property, { ...state, loading: false, selectedId: property.id }) + ), + on(PropertyActions.loadPropertyFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Create Property + on(PropertyActions.createProperty, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(PropertyActions.createPropertySuccess, (state, { property }) => + adapter.addOne(property, { ...state, loading: false }) + ), + on(PropertyActions.createPropertyFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Update Property + on(PropertyActions.updateProperty, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(PropertyActions.updatePropertySuccess, (state, { property }) => + adapter.updateOne({ id: property.id, changes: property }, { ...state, loading: false }) + ), + on(PropertyActions.updatePropertyFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Delete Property + on(PropertyActions.deleteProperty, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(PropertyActions.deletePropertySuccess, (state, { id }) => + adapter.removeOne(id, { ...state, loading: false }) + ), + on(PropertyActions.deletePropertyFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Selection + on(PropertyActions.selectProperty, (state, { id }) => ({ + ...state, + selectedId: id, + })), + on(PropertyActions.clearSelectedProperty, (state) => ({ + ...state, + selectedId: null, + })) +); + +export const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors(); diff --git a/src/frontend/src/app/features/properties/store/property.selectors.ts b/src/frontend/src/app/features/properties/store/property.selectors.ts new file mode 100644 index 0000000..ad2b0e9 --- /dev/null +++ b/src/frontend/src/app/features/properties/store/property.selectors.ts @@ -0,0 +1,23 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { PropertyState, selectAll, selectEntities } from './property.reducer'; + +export const selectPropertyState = createFeatureSelector('properties'); + +export const selectAllProperties = createSelector(selectPropertyState, selectAll); + +export const selectPropertyEntities = createSelector(selectPropertyState, selectEntities); + +export const selectPropertyLoading = createSelector(selectPropertyState, (state) => state.loading); + +export const selectPropertyError = createSelector(selectPropertyState, (state) => state.error); + +export const selectSelectedPropertyId = createSelector( + selectPropertyState, + (state) => state.selectedId +); + +export const selectSelectedProperty = createSelector( + selectPropertyEntities, + selectSelectedPropertyId, + (entities, selectedId) => (selectedId ? entities[selectedId] : null) +); diff --git a/src/frontend/src/app/features/properties/store/unit.actions.ts b/src/frontend/src/app/features/properties/store/unit.actions.ts new file mode 100644 index 0000000..fc3f5f7 --- /dev/null +++ b/src/frontend/src/app/features/properties/store/unit.actions.ts @@ -0,0 +1,29 @@ +import { createActionGroup, props } from '@ngrx/store'; +import { Unit, CreateUnitRequest, UpdateUnitRequest } from '../../../core/models/unit.model'; + +export const UnitActions = createActionGroup({ + source: 'Unit', + events: { + 'Load Units': props<{ buildingId?: number; active?: boolean }>(), + 'Load Units Success': props<{ units: Unit[] }>(), + 'Load Units Failure': props<{ error: any }>(), + + 'Load Unit': props<{ id: number }>(), + 'Load Unit Success': props<{ unit: Unit }>(), + 'Load Unit Failure': props<{ error: any }>(), + + 'Create Unit': props<{ request: CreateUnitRequest }>(), + 'Create Unit Success': props<{ unit: Unit }>(), + 'Create Unit Failure': props<{ error: any }>(), + + 'Update Unit': props<{ id: number; request: UpdateUnitRequest }>(), + 'Update Unit Success': props<{ unit: Unit }>(), + 'Update Unit Failure': props<{ error: any }>(), + + 'Delete Unit': props<{ id: number }>(), + 'Delete Unit Success': props<{ id: number }>(), + 'Delete Unit Failure': props<{ error: any }>(), + + 'Select Unit': props<{ id: number | null }>(), + }, +}); diff --git a/src/frontend/src/app/features/properties/store/unit.effects.ts b/src/frontend/src/app/features/properties/store/unit.effects.ts new file mode 100644 index 0000000..a10c99f --- /dev/null +++ b/src/frontend/src/app/features/properties/store/unit.effects.ts @@ -0,0 +1,104 @@ +import { Injectable, inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { of } from 'rxjs'; +import { map, mergeMap, catchError } from 'rxjs/operators'; +import { UnitService } from '../../../core/services/unit.service'; +import { UnitActions } from './unit.actions'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Unit, UnitListResponse } from '../../../core/models'; + +@Injectable() +export class UnitEffects { + private actions$ = inject(Actions); + private unitService = inject(UnitService); + private snackBar = inject(MatSnackBar); + + loadUnits$ = createEffect(() => + this.actions$.pipe( + ofType(UnitActions.loadUnits), + mergeMap(({ buildingId, active }) => { + if (buildingId) { + return this.unitService.getUnitsByBuilding(buildingId).pipe( + map((response: UnitListResponse) => + UnitActions.loadUnitsSuccess({ units: response.units }) + ), + catchError((error) => of(UnitActions.loadUnitsFailure({ error }))) + ); + } else { + return this.unitService.getUnits({ active }).pipe( + map((response: UnitListResponse) => + UnitActions.loadUnitsSuccess({ units: response.units }) + ), + catchError((error) => of(UnitActions.loadUnitsFailure({ error }))) + ); + } + }) + ) + ); + + loadUnit$ = createEffect(() => + this.actions$.pipe( + ofType(UnitActions.loadUnit), + mergeMap(({ id }) => + this.unitService.getUnit(id).pipe( + map((unit: Unit) => UnitActions.loadUnitSuccess({ unit })), + catchError((error) => of(UnitActions.loadUnitFailure({ error }))) + ) + ) + ) + ); + + createUnit$ = createEffect(() => + this.actions$.pipe( + ofType(UnitActions.createUnit), + mergeMap(({ request }) => + this.unitService.createUnit(request).pipe( + map((unit: Unit) => { + this.snackBar.open('Unit created successfully', 'Close', { duration: 3000 }); + return UnitActions.createUnitSuccess({ unit }); + }), + catchError((error) => { + this.snackBar.open('Error creating unit', 'Close', { duration: 3000 }); + return of(UnitActions.createUnitFailure({ error })); + }) + ) + ) + ) + ); + + updateUnit$ = createEffect(() => + this.actions$.pipe( + ofType(UnitActions.updateUnit), + mergeMap(({ id, request }) => + this.unitService.updateUnit(id, request).pipe( + map((unit: Unit) => { + this.snackBar.open('Unit updated successfully', 'Close', { duration: 3000 }); + return UnitActions.updateUnitSuccess({ unit }); + }), + catchError((error) => { + this.snackBar.open('Error updating unit', 'Close', { duration: 3000 }); + return of(UnitActions.updateUnitFailure({ error })); + }) + ) + ) + ) + ); + + deleteUnit$ = createEffect(() => + this.actions$.pipe( + ofType(UnitActions.deleteUnit), + mergeMap(({ id }) => + this.unitService.deleteUnit(id).pipe( + map(() => { + this.snackBar.open('Unit deleted successfully', 'Close', { duration: 3000 }); + return UnitActions.deleteUnitSuccess({ id }); + }), + catchError((error) => { + this.snackBar.open('Error deleting unit', 'Close', { duration: 3000 }); + return of(UnitActions.deleteUnitFailure({ error })); + }) + ) + ) + ) + ); +} diff --git a/src/frontend/src/app/features/properties/store/unit.reducer.ts b/src/frontend/src/app/features/properties/store/unit.reducer.ts new file mode 100644 index 0000000..3f65a61 --- /dev/null +++ b/src/frontend/src/app/features/properties/store/unit.reducer.ts @@ -0,0 +1,103 @@ +import { createReducer, on } from '@ngrx/store'; +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { Unit } from '../../../core/models/unit.model'; +import { UnitActions } from './unit.actions'; + +export interface UnitState extends EntityState { + selectedId: number | null; + loading: boolean; + error: any; +} + +export const unitAdapter: EntityAdapter = createEntityAdapter(); + +export const initialUnitState: UnitState = unitAdapter.getInitialState({ + selectedId: null, + loading: false, + error: null, +}); + +export const unitReducer = createReducer( + initialUnitState, + + // Load Units + on(UnitActions.loadUnits, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(UnitActions.loadUnitsSuccess, (state, { units }) => + unitAdapter.setAll(units, { ...state, loading: false }) + ), + on(UnitActions.loadUnitsFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Load Unit + on(UnitActions.loadUnit, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(UnitActions.loadUnitSuccess, (state, { unit }) => + unitAdapter.upsertOne(unit, { ...state, loading: false, selectedId: unit.id }) + ), + on(UnitActions.loadUnitFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Create Unit + on(UnitActions.createUnit, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(UnitActions.createUnitSuccess, (state, { unit }) => + unitAdapter.addOne(unit, { ...state, loading: false }) + ), + on(UnitActions.createUnitFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Update Unit + on(UnitActions.updateUnit, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(UnitActions.updateUnitSuccess, (state, { unit }) => + unitAdapter.updateOne({ id: unit.id, changes: unit }, { ...state, loading: false }) + ), + on(UnitActions.updateUnitFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Delete Unit + on(UnitActions.deleteUnit, (state) => ({ + ...state, + loading: true, + error: null, + })), + on(UnitActions.deleteUnitSuccess, (state, { id }) => + unitAdapter.removeOne(id, { ...state, loading: false }) + ), + on(UnitActions.deleteUnitFailure, (state, { error }) => ({ + ...state, + loading: false, + error, + })), + + // Select Unit + on(UnitActions.selectUnit, (state, { id }) => ({ + ...state, + selectedId: id, + })) +); diff --git a/src/frontend/src/app/features/properties/store/unit.selectors.ts b/src/frontend/src/app/features/properties/store/unit.selectors.ts new file mode 100644 index 0000000..7b0da1f --- /dev/null +++ b/src/frontend/src/app/features/properties/store/unit.selectors.ts @@ -0,0 +1,22 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { UnitState, unitAdapter } from './unit.reducer'; + +export const selectUnitState = createFeatureSelector('units'); + +const { selectAll, selectEntities, selectIds, selectTotal } = unitAdapter.getSelectors(); + +export const selectAllUnits = createSelector(selectUnitState, selectAll); + +export const selectUnitEntities = createSelector(selectUnitState, selectEntities); + +export const selectUnitLoading = createSelector(selectUnitState, (state) => state.loading); + +export const selectUnitError = createSelector(selectUnitState, (state) => state.error); + +export const selectSelectedUnitId = createSelector(selectUnitState, (state) => state.selectedId); + +export const selectSelectedUnit = createSelector( + selectUnitEntities, + selectSelectedUnitId, + (entities, selectedId) => (selectedId ? entities[selectedId] : null) +); diff --git a/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.html b/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.html new file mode 100644 index 0000000..206f447 --- /dev/null +++ b/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.html @@ -0,0 +1,84 @@ +

unit-form-dialog works!

+

+ {{ isEditMode ? 'edit' : 'add' }} + {{ dialogTitle }} +

+ + +
+ business + {{ buildingContext }} +
+ +
+ + Unit Number + + tag + Unique identifier for this unit + {{ getErrorMessage('unit_number') }} + + + + Unit Name (Optional) + + label + {{ getErrorMessage('unit_name') }} + + + + Unit Type + + + {{ getUnitTypeIcon(type) }} + {{ type }} + + + category + {{ getErrorMessage('unit_type') }} + + +
+ + Floor + + layers + Optional + {{ getErrorMessage('floor') }} + + + + Section + + view_module + Optional + {{ getErrorMessage('section') }} + +
+ + + Monthly Rent + + attach_money + ৳  + {{ getErrorMessage('monthly_rent') }} + +
+
+ + + + + diff --git a/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.scss b/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.scss new file mode 100644 index 0000000..4292c5e --- /dev/null +++ b/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.scss @@ -0,0 +1,111 @@ +:host { + display: block; +} + +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + padding: 24px 24px 16px; + + mat-icon { + color: var(--primary-color, #1976d2); + } +} + +mat-dialog-content { + padding: 0 24px 24px; + min-width: 500px; + max-width: 600px; +} + +.context-breadcrumb { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 4px; + margin-bottom: 16px; + + mat-icon { + color: rgba(0, 0, 0, 0.54); + font-size: 20px; + width: 20px; + height: 20px; + } + + span { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + font-size: 14px; + } +} + +.unit-form { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 8px; +} + +.full-width { + width: 100%; +} + +.form-row { + display: flex; + gap: 16px; + + .half-width { + flex: 1; + } +} + +mat-form-field { + mat-icon[matPrefix] { + margin-right: 8px; + color: rgba(0, 0, 0, 0.54); + } +} + +mat-option { + mat-icon { + margin-right: 8px; + vertical-align: middle; + font-size: 20px; + width: 20px; + height: 20px; + } +} + +mat-dialog-actions { + padding: 16px 24px; + border-top: 1px solid rgba(0, 0, 0, 0.12); + + button { + mat-icon { + margin-right: 4px; + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +// Responsive +@media (max-width: 600px) { + mat-dialog-content { + min-width: auto; + width: 100%; + } + + .form-row { + flex-direction: column; + + .half-width { + width: 100%; + } + } +} diff --git a/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.ts b/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.ts new file mode 100644 index 0000000..5fa4d3e --- /dev/null +++ b/src/frontend/src/app/features/properties/unit-form-dialog/unit-form-dialog.ts @@ -0,0 +1,154 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { MatDialogRef, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatSelectModule } from '@angular/material/select'; +import { MatIconModule } from '@angular/material/icon'; +import { Unit, UnitType, Building, Property } from '../../../core/models'; + +export interface UnitFormDialogData { + unit?: Unit; + building: Building; + property: Property; + mode: 'create' | 'edit'; +} + +@Component({ + selector: 'app-unit-form-dialog', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatSelectModule, + MatIconModule, + ], + templateUrl: './unit-form-dialog.html', + styleUrls: ['./unit-form-dialog.scss'], +}) +export class UnitFormDialogComponent implements OnInit { + private fb = inject(FormBuilder); + private dialogRef = inject(MatDialogRef); + public data = inject(MAT_DIALOG_DATA); + + unitForm!: FormGroup; + unitTypes: UnitType[] = ['Shop', 'Apartment', 'Office', 'Parking', 'Storage', 'Other']; + + ngOnInit() { + this.initializeForm(); + } + + private initializeForm() { + const unit = this.data.unit; + + this.unitForm = this.fb.group({ + unit_number: [unit?.unit_number || '', [Validators.required, Validators.maxLength(50)]], + unit_name: [unit?.unit_name || '', [Validators.maxLength(100)]], + unit_type: [unit?.unit_type || 'Apartment', [Validators.required]], + floor: [unit?.floor || null, [Validators.min(0), Validators.max(200)]], + section: [unit?.section || '', [Validators.maxLength(50)]], + monthly_rent: [unit?.monthly_rent || null, [Validators.required, Validators.min(0)]], + }); + + // Disable unit_number in edit mode (it's the identifier) + if (this.data.mode === 'edit') { + this.unitForm.get('unit_number')?.disable(); + } + } + + onSubmit() { + if (this.unitForm.valid) { + const formValue = this.unitForm.getRawValue(); + + // Remove unit_number from updates (it's immutable) + if (this.data.mode === 'edit') { + const { unit_number, ...updateData } = formValue; + this.dialogRef.close(updateData); + } else { + // Add building_id and property_id for creation + this.dialogRef.close({ + ...formValue, + building_id: this.data.building.id, + property_id: this.data.property.id, + }); + } + } else { + // Mark all fields as touched to show validation errors + Object.keys(this.unitForm.controls).forEach((key) => { + this.unitForm.get(key)?.markAsTouched(); + }); + } + } + + onCancel() { + this.dialogRef.close(); + } + + getErrorMessage(fieldName: string): string { + const control = this.unitForm.get(fieldName); + if (!control || !control.errors || !control.touched) { + return ''; + } + + if (control.errors['required']) { + return `${this.getFieldLabel(fieldName)} is required`; + } + if (control.errors['maxlength']) { + return `Maximum length is ${control.errors['maxlength'].requiredLength}`; + } + if (control.errors['min']) { + return `Minimum value is ${control.errors['min'].min}`; + } + if (control.errors['max']) { + return `Maximum value is ${control.errors['max'].max}`; + } + return 'Invalid value'; + } + + private getFieldLabel(fieldName: string): string { + const labels: { [key: string]: string } = { + unit_number: 'Unit Number', + unit_name: 'Unit Name', + unit_type: 'Unit Type', + floor: 'Floor', + section: 'Section', + monthly_rent: 'Monthly Rent', + }; + return labels[fieldName] || fieldName; + } + + get isEditMode(): boolean { + return this.data.mode === 'edit'; + } + + get dialogTitle(): string { + return this.isEditMode ? 'Edit Unit' : 'Add New Unit'; + } + + get buildingContext(): string { + return `${this.data.property.property_name} › ${this.data.building.building_name}`; + } + + getUnitTypeIcon(type: UnitType): string { + switch (type) { + case 'Shop': + return 'store'; + case 'Apartment': + return 'home'; + case 'Office': + return 'business'; + case 'Parking': + return 'local_parking'; + case 'Storage': + return 'inventory_2'; + default: + return 'meeting_room'; + } + } +} diff --git a/src/frontend/src/environments/environment.ts b/src/frontend/src/environments/environment.ts index 7f55a4c..e870aa7 100644 --- a/src/frontend/src/environments/environment.ts +++ b/src/frontend/src/environments/environment.ts @@ -1,4 +1,5 @@ export const environment = { production: false, apiUrl: '/api/v1', + //apiUrl: 'http://localhost:8080/api/v1', };