diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 5bcf313..76f2387 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -1,9 +1,6 @@ name: Frontend CI on: - push: - branches: - - '**' pull_request: branches: - '**' diff --git a/scripts/dev-setup.bat b/scripts/dev-setup.bat index 0287214..db02649 100644 --- a/scripts/dev-setup.bat +++ b/scripts/dev-setup.bat @@ -18,15 +18,15 @@ timeout /t 10 /nobreak > nul REM Run database migrations echo Running database migrations... -cd backend\api && go run cmd\server\main.go migrate || echo Migration will be implemented in later tasks +cd backend/api && go run cmd/server/main.go migrate || echo Migration will be implemented in later tasks cd ..\.. echo Development environment setup complete! echo. echo To start the services: -echo 1. Go API: cd backend\api ^&^& go run cmd\server\main.go +echo 1. Go API: cd backend/api ^&^& go run cmd/server/main.go echo 2. Angular Frontend: cd frontend ^&^& npm install ^&^& npm start -echo 3. C# Notification Service: cd backend\notification-service ^&^& dotnet run +echo 3. C# Notification Service: cd backend/notification-service ^&^& dotnet run echo. echo Or use Docker Compose: docker-compose up diff --git a/src/backend/api/internal/handlers/building_handler_comprehensive_integration_test.go b/src/backend/api/internal/handlers/building_handler_comprehensive_integration_test.go index d4a9ff6..d34d1a9 100644 --- a/src/backend/api/internal/handlers/building_handler_comprehensive_integration_test.go +++ b/src/backend/api/internal/handlers/building_handler_comprehensive_integration_test.go @@ -103,7 +103,7 @@ func (suite *BuildingIntegrationTestSuite) SetupTest() { func (suite *BuildingIntegrationTestSuite) setupTestRoutes(userHandler *UserHandler, propertyHandler *PropertyHandler, auditService *database.AuditService) { // Add middleware suite.router.Use(middleware.SecurityHeadersMiddleware()) - suite.router.Use(middleware.CORS()) + suite.router.Use(middleware.CORS("test")) suite.router.Use(gin.Logger()) suite.router.Use(gin.Recovery()) diff --git a/src/backend/api/internal/handlers/tenant_handler.go b/src/backend/api/internal/handlers/tenant_handler.go index f2f2821..f5d720b 100644 --- a/src/backend/api/internal/handlers/tenant_handler.go +++ b/src/backend/api/internal/handlers/tenant_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/ysnarafat/tenantly/internal/interfaces" @@ -40,3 +41,17 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) { c.JSON(http.StatusCreated, tenant) } + +// GetAllTenants handles fetching all tenants +func (h *TenantHandler) GetAllTenants(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) + + response, err := h.tenantService.GetAllTenants(page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, response) +} diff --git a/src/backend/api/internal/interfaces/interfaces.go b/src/backend/api/internal/interfaces/interfaces.go index 1ca06fb..46c095b 100644 --- a/src/backend/api/internal/interfaces/interfaces.go +++ b/src/backend/api/internal/interfaces/interfaces.go @@ -259,11 +259,13 @@ type TenantRepositoryInterface interface { CheckNIDExists(nid string, excludeID int) (bool, error) GetByID(id int) (*models.Tenant, error) GetByUnitID(unitID int) (*models.Tenant, error) + GetAll(page, pageSize int) ([]*models.Tenant, int, error) } // TenantServiceInterface defines the interface for tenant service operations type TenantServiceInterface interface { CreateTenant(req *models.CreateTenantRequest, userID int) (*models.TenantResponse, error) + GetAllTenants(page, pageSize int) (*models.TenantListResponse, error) } // PropertyRepositoryInterface defines the interface for property repository operations diff --git a/src/backend/api/internal/interfaces/mocks/interfaces_mock.go b/src/backend/api/internal/interfaces/mocks/interfaces_mock.go index 9e4b90e..18ac9b0 100644 --- a/src/backend/api/internal/interfaces/mocks/interfaces_mock.go +++ b/src/backend/api/internal/interfaces/mocks/interfaces_mock.go @@ -44,6 +44,14 @@ func (m *TenantRepositoryInterface) GetByUnitID(unitID int) (*models.Tenant, err return args.Get(0).(*models.Tenant), args.Error(1) } +func (m *TenantRepositoryInterface) GetAll(page, pageSize int) ([]*models.Tenant, int, error) { + args := m.Called(page, pageSize) + if args.Get(0) == nil { + return nil, args.Int(1), args.Error(2) + } + return args.Get(0).([]*models.Tenant), args.Int(1), args.Error(2) +} + // AuditServiceInterface is a mock of AuditServiceInterface type AuditServiceInterface struct { mock.Mock diff --git a/src/backend/api/internal/models/tenant.go b/src/backend/api/internal/models/tenant.go index d334e61..81dd2b0 100644 --- a/src/backend/api/internal/models/tenant.go +++ b/src/backend/api/internal/models/tenant.go @@ -53,6 +53,12 @@ func (t *Tenant) ToResponse() *TenantResponse { } } +// TenantListResponse represents a paginated list of tenants +type TenantListResponse struct { + Tenants []*TenantResponse `json:"tenants"` + Pagination *PaginationInfo `json:"pagination"` +} + type CreateTenantRequest struct { Name string `json:"name" binding:"required,max=100"` TenantType TenantType `json:"tenant_type" binding:"required,oneof=Individual Business"` diff --git a/src/backend/api/internal/repositories/tenant_repository.go b/src/backend/api/internal/repositories/tenant_repository.go index 314a6ba..bb5eed0 100644 --- a/src/backend/api/internal/repositories/tenant_repository.go +++ b/src/backend/api/internal/repositories/tenant_repository.go @@ -141,3 +141,66 @@ func (r *TenantRepository) GetByID(id int) (*models.Tenant, error) { func (r *TenantRepository) GetByUnitID(unitID int) (*models.Tenant, error) { return nil, fmt.Errorf("not implemented yet") } + +// GetAll retrieves all active tenants with pagination +func (r *TenantRepository) GetAll(page, pageSize int) ([]*models.Tenant, int, error) { + // Set defaults + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + offset := (page - 1) * pageSize + + // Get total count + var totalCount int + err := r.db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s = true", columns.TenantTable, columns.TenantActive)).Scan(&totalCount) + if err != nil { + return nil, 0, fmt.Errorf("failed to count tenants: %w", err) + } + + query := fmt.Sprintf(` + SELECT %s + FROM %s + WHERE %s = true + ORDER BY %s DESC + LIMIT $1 OFFSET $2`, + columns.TenantAllColumns(), + columns.TenantTable, + columns.TenantActive, + columns.TenantCreatedAt) + + rows, err := r.db.Query(query, pageSize, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get all tenants: %w", err) + } + defer rows.Close() + + var tenants []*models.Tenant + for rows.Next() { + tenant := &models.Tenant{} + err := rows.Scan( + &tenant.ID, + &tenant.Name, + &tenant.TenantType, + &tenant.PhoneNumber, + &tenant.Email, + &tenant.NIDNumber, + &tenant.Address, + &tenant.Active, + &tenant.CreatedAt, + &tenant.UpdatedAt, + ) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan tenant: %w", err) + } + tenants = append(tenants, tenant) + } + + if err = rows.Err(); err != nil { + return nil, 0, fmt.Errorf("error iterating tenants: %w", err) + } + + return tenants, totalCount, nil +} diff --git a/src/backend/api/internal/server/server.go b/src/backend/api/internal/server/server.go index 3667bdb..2c2be9d 100644 --- a/src/backend/api/internal/server/server.go +++ b/src/backend/api/internal/server/server.go @@ -186,7 +186,7 @@ func (s *Server) setupRoutes() { tenants := protected.Group("/tenants") { - tenants.GET("", s.handlePlaceholder("Get tenants")) + tenants.GET("", middleware.RequireAnyRole(), tenantHandler.GetAllTenants) tenants.POST("", middleware.RequireAdminOrPropertyManager(), tenantHandler.CreateTenant) tenants.GET("/:id", s.handlePlaceholder("Get tenant")) tenants.PUT("/:id", s.handlePlaceholder("Update tenant")) diff --git a/src/backend/api/internal/services/tenant_service.go b/src/backend/api/internal/services/tenant_service.go index 0e8acfa..af49a3f 100644 --- a/src/backend/api/internal/services/tenant_service.go +++ b/src/backend/api/internal/services/tenant_service.go @@ -61,3 +61,43 @@ func (s *TenantService) CreateTenant(req *models.CreateTenantRequest, userID int return tenant.ToResponse(), nil } + +// GetAllTenants retrieves all active tenants with pagination +func (s *TenantService) GetAllTenants(page, pageSize int) (*models.TenantListResponse, error) { + // Defaults if 0 + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 10 + } + + tenants, totalCount, err := s.tenantRepo.GetAll(page, pageSize) + if err != nil { + return nil, fmt.Errorf("failed to get tenants: %w", err) + } + + var responses []*models.TenantResponse + for _, tenant := range tenants { + responses = append(responses, tenant.ToResponse()) + } + + // Calculate pagination info + totalPages := (totalCount + pageSize - 1) / pageSize + hasNext := page < totalPages + hasPrev := page > 1 + + pagination := &models.PaginationInfo{ + CurrentPage: page, + PageSize: pageSize, + TotalItems: totalCount, + TotalPages: totalPages, + HasNext: hasNext, + HasPrev: hasPrev, + } + + return &models.TenantListResponse{ + Tenants: responses, + Pagination: pagination, + }, nil +} diff --git a/src/backend/api/internal/services/tenant_service_test.go b/src/backend/api/internal/services/tenant_service_test.go index 8aa53f5..8d6d63e 100644 --- a/src/backend/api/internal/services/tenant_service_test.go +++ b/src/backend/api/internal/services/tenant_service_test.go @@ -89,3 +89,37 @@ func TestTenantService_CreateTenant(t *testing.T) { mockRepo.AssertExpectations(t) }) } + +func TestTenantService_GetAllTenants(t *testing.T) { + mockRepo := new(mocks.TenantRepositoryInterface) + mockAudit := new(mocks.AuditServiceInterface) + service := NewTenantService(mockRepo, mockAudit) + + t.Run("success", func(t *testing.T) { + tenants := []*models.Tenant{ + {ID: 1, Name: "Tenant 1", Active: true}, + {ID: 2, Name: "Tenant 2", Active: true}, + } + totalCount := 2 + + mockRepo.On("GetAll", 1, 10).Return(tenants, totalCount, nil).Once() + + resp, err := service.GetAllTenants(1, 10) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 2, len(resp.Tenants)) + assert.Equal(t, totalCount, resp.Pagination.TotalItems) + mockRepo.AssertExpectations(t) + }) + + t.Run("repo error", func(t *testing.T) { + mockRepo.On("GetAll", 1, 10).Return([]*models.Tenant{}, 0, fmt.Errorf("db error")).Once() + + resp, err := service.GetAllTenants(1, 10) + + assert.Error(t, err) + assert.Nil(t, resp) + mockRepo.AssertExpectations(t) + }) +} diff --git a/src/frontend/src/app/core/models/tenant.model.ts b/src/frontend/src/app/core/models/tenant.model.ts index 10462e3..6859922 100644 --- a/src/frontend/src/app/core/models/tenant.model.ts +++ b/src/frontend/src/app/core/models/tenant.model.ts @@ -1,5 +1,19 @@ export type TenantType = 'Individual' | 'Business'; +export interface PaginationInfo { + current_page: number; + page_size: number; + total_items: number; + total_pages: number; + has_next: boolean; + has_prev: boolean; +} + +export interface TenantListResponse { + tenants: Tenant[]; + pagination: PaginationInfo; +} + export interface Tenant { id: number; name: string; diff --git a/src/frontend/src/app/core/services/tenant.service.ts b/src/frontend/src/app/core/services/tenant.service.ts index 20fd1e3..720350c 100644 --- a/src/frontend/src/app/core/services/tenant.service.ts +++ b/src/frontend/src/app/core/services/tenant.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { CreateTenantRequest, Tenant } from '../models/tenant.model'; +import { CreateTenantRequest, Tenant, TenantListResponse } from '../models/tenant.model'; import { environment } from '../../../environments/environment'; @Injectable({ @@ -14,4 +14,13 @@ export class TenantService { createTenant(tenant: CreateTenantRequest): Observable { return this.http.post(this.apiUrl, tenant); } + + getAllTenants(page: number = 1, pageSize: number = 10): Observable { + return this.http.get(this.apiUrl, { + params: { + page: page.toString(), + page_size: pageSize.toString(), + }, + }); + } } diff --git a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.html b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.html index 163f686..b38449c 100644 --- a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.html +++ b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.html @@ -9,7 +9,65 @@

Tenants

-

Tenant management functionality will be implemented in later tasks

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{tenant.name}}Type{{tenant.tenant_type}}Email{{tenant.email || '-'}}Phone{{tenant.phone_number || '-'}}Status + + {{tenant.active ? 'Active' : 'Inactive'}} + + Joined{{tenant.created_at | date:'mediumDate'}}
No tenants found
+
+ +
diff --git a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.ts b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.ts index 02bf9ce..e66da2a 100644 --- a/src/frontend/src/app/features/tenants/tenant-list/tenant-list.ts +++ b/src/frontend/src/app/features/tenants/tenant-list/tenant-list.ts @@ -1,25 +1,68 @@ -import { Component, inject } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; import { MatCardModule } from '@angular/material/card'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { TenantService } from '../../../core/services/tenant.service'; import { TenantFormDialogComponent } from '../tenant-form-dialog/tenant-form-dialog'; import { AuthFacade } from '../../../store/auth/auth.facade'; import { take } from 'rxjs'; +import { Tenant, PaginationInfo } from '../../../core/models/tenant.model'; @Component({ selector: 'app-tenant-list', standalone: true, - imports: [MatCardModule, MatButtonModule, MatIconModule, MatDialogModule], + imports: [ + CommonModule, + MatCardModule, + MatButtonModule, + MatIconModule, + MatDialogModule, + MatTableModule, + MatPaginatorModule, + ], templateUrl: './tenant-list.html', styleUrls: ['./tenant-list.scss'], }) -export class TenantList { +export class TenantList implements OnInit { private dialog = inject(MatDialog); private tenantService = inject(TenantService); private authFacade = inject(AuthFacade); + tenants: Tenant[] = []; + displayedColumns: string[] = ['name', 'type', 'email', 'phone', 'status', 'created']; + pagination: PaginationInfo = { + current_page: 1, + page_size: 10, + total_items: 0, + total_pages: 0, + has_next: false, + has_prev: false, + }; + + ngOnInit() { + this.loadTenants(); + } + + loadTenants(page: number = 1, pageSize: number = 10) { + this.tenantService.getAllTenants(page, pageSize).subscribe({ + next: (response) => { + this.tenants = response.tenants; + this.pagination = response.pagination; + }, + error: (err) => { + console.error('Error fetching tenants:', err); + }, + }); + } + + onPageChange(event: PageEvent) { + this.loadTenants(event.pageIndex + 1, event.pageSize); + } + addTenant() { const dialogRef = this.dialog.open(TenantFormDialogComponent, { width: '600px', @@ -33,7 +76,7 @@ export class TenantList { this.tenantService.createTenant(result).subscribe({ next: (tenant) => { console.log('Tenant created successfully:', tenant); - // In a real scenario, we might want to refresh the list or show a snackbar + this.loadTenants(); // Refresh list }, error: (err) => { console.error('Error creating tenant:', err);