Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
name: Frontend CI

on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
Expand Down
6 changes: 3 additions & 3 deletions scripts/dev-setup.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
15 changes: 15 additions & 0 deletions src/backend/api/internal/handlers/tenant_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"net/http"
"strconv"

"github.com/gin-gonic/gin"
"github.com/ysnarafat/tenantly/internal/interfaces"
Expand Down Expand Up @@ -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)
}
2 changes: 2 additions & 0 deletions src/backend/api/internal/interfaces/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/backend/api/internal/interfaces/mocks/interfaces_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/backend/api/internal/models/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
63 changes: 63 additions & 0 deletions src/backend/api/internal/repositories/tenant_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 1 addition & 1 deletion src/backend/api/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
40 changes: 40 additions & 0 deletions src/backend/api/internal/services/tenant_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
34 changes: 34 additions & 0 deletions src/backend/api/internal/services/tenant_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
14 changes: 14 additions & 0 deletions src/frontend/src/app/core/models/tenant.model.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
11 changes: 10 additions & 1 deletion src/frontend/src/app/core/services/tenant.service.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -14,4 +14,13 @@ export class TenantService {
createTenant(tenant: CreateTenantRequest): Observable<Tenant> {
return this.http.post<Tenant>(this.apiUrl, tenant);
}

getAllTenants(page: number = 1, pageSize: number = 10): Observable<TenantListResponse> {
return this.http.get<TenantListResponse>(this.apiUrl, {
params: {
page: page.toString(),
page_size: pageSize.toString(),
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,65 @@ <h1>Tenants</h1>

<mat-card>
<mat-card-content>
<p>Tenant management functionality will be implemented in later tasks</p>
<div class="table-container">
<table mat-table [dataSource]="tenants" class="mat-elevation-z0">
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let tenant">{{tenant.name}}</td>
</ng-container>

<!-- Type Column -->
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let tenant">{{tenant.tenant_type}}</td>
</ng-container>

<!-- Email Column -->
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef>Email</th>
<td mat-cell *matCellDef="let tenant">{{tenant.email || '-'}}</td>
</ng-container>

<!-- Phone Column -->
<ng-container matColumnDef="phone">
<th mat-header-cell *matHeaderCellDef>Phone</th>
<td mat-cell *matCellDef="let tenant">{{tenant.phone_number || '-'}}</td>
</ng-container>

<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let tenant">
<span [class.active-status]="tenant.active" [class.inactive-status]="!tenant.active">
{{tenant.active ? 'Active' : 'Inactive'}}
</span>
</td>
</ng-container>

<!-- Created Column -->
<ng-container matColumnDef="created">
<th mat-header-cell *matHeaderCellDef>Joined</th>
<td mat-cell *matCellDef="let tenant">{{tenant.created_at | date:'mediumDate'}}</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>

<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" colspan="6">No tenants found</td>
</tr>
</table>
</div>
<mat-paginator
[length]="pagination.total_items"
[pageSize]="pagination.page_size"
[pageSizeOptions]="[5, 10, 25, 100]"
[pageIndex]="pagination.current_page - 1"
(page)="onPageChange($event)"
aria-label="Select page of tenants"
>
</mat-paginator>
</mat-card-content>
</mat-card>
</div>
Loading