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
42 changes: 42 additions & 0 deletions src/backend/api/internal/handlers/tenant_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package handlers

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/ysnarafat/tenantly/internal/interfaces"
"github.com/ysnarafat/tenantly/internal/models"
)

// TenantHandler handles HTTP requests for tenant operations
type TenantHandler struct {
tenantService interfaces.TenantServiceInterface
}

// NewTenantHandler creates a new TenantHandler
func NewTenantHandler(tenantService interfaces.TenantServiceInterface) *TenantHandler {
return &TenantHandler{tenantService: tenantService}
}

// CreateTenant handles tenant creation
func (h *TenantHandler) CreateTenant(c *gin.Context) {
var req models.CreateTenantRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

userID := c.GetInt("userID")
tenant, err := h.tenantService.CreateTenant(&req, userID)
if err != nil {
// Check for uniqueness errors
if err.Error() == "email already exists" || err.Error() == "NID number already exists" {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusCreated, tenant)
}
8 changes: 8 additions & 0 deletions src/backend/api/internal/interfaces/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,18 @@ type NotificationServiceInterface interface {

// TenantRepositoryInterface defines the interface for tenant repository operations
type TenantRepositoryInterface interface {
Create(req *models.CreateTenantRequest) (*models.Tenant, error)
CheckEmailExists(email string, excludeID int) (bool, error)
CheckNIDExists(nid string, excludeID int) (bool, error)
GetByID(id int) (*models.Tenant, error)
GetByUnitID(unitID int) (*models.Tenant, error)
}

// TenantServiceInterface defines the interface for tenant service operations
type TenantServiceInterface interface {
CreateTenant(req *models.CreateTenantRequest, userID int) (*models.TenantResponse, error)
}

// PropertyRepositoryInterface defines the interface for property repository operations
type PropertyRepositoryInterface interface {
GetByID(id int) (*models.Property, error)
Expand Down
60 changes: 60 additions & 0 deletions src/backend/api/internal/interfaces/mocks/interfaces_mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package mocks

import (
"github.com/stretchr/testify/mock"
"github.com/ysnarafat/tenantly/internal/models"
)

// TenantRepositoryInterface is a mock of TenantRepositoryInterface
type TenantRepositoryInterface struct {
mock.Mock
}

func (m *TenantRepositoryInterface) Create(req *models.CreateTenantRequest) (*models.Tenant, error) {
args := m.Called(req)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.Tenant), args.Error(1)
}

func (m *TenantRepositoryInterface) CheckEmailExists(email string, excludeID int) (bool, error) {
args := m.Called(email, excludeID)
return args.Bool(0), args.Error(1)
}

func (m *TenantRepositoryInterface) CheckNIDExists(nid string, excludeID int) (bool, error) {
args := m.Called(nid, excludeID)
return args.Bool(0), args.Error(1)
}

func (m *TenantRepositoryInterface) GetByID(id int) (*models.Tenant, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.Tenant), args.Error(1)
}

func (m *TenantRepositoryInterface) GetByUnitID(unitID int) (*models.Tenant, error) {
args := m.Called(unitID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.Tenant), args.Error(1)
}

// AuditServiceInterface is a mock of AuditServiceInterface
type AuditServiceInterface struct {
mock.Mock
}

func (m *AuditServiceInterface) LogSystemAction(action, entity string, entityID *int, oldData, newData interface{}) error {
args := m.Called(action, entity, entityID, oldData, newData)
return args.Error(0)
}

func (m *AuditServiceInterface) LogUserAction(userID int, action, entity string, entityID *int, oldData, newData interface{}) error {
args := m.Called(userID, action, entity, entityID, oldData, newData)
return args.Error(0)
}
23 changes: 23 additions & 0 deletions src/backend/api/internal/models/columns/tenant_columns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package columns

// Tenant table and column constants
const (
TenantTable = "tenants"

// Tenant columns
TenantID = "id"
TenantName = "name"
TenantType = "tenant_type"
TenantPhoneNumber = "phone_number"
TenantEmail = "email"
TenantNIDNumber = "nid_number"
TenantAddress = "address"
TenantActive = "active"
TenantCreatedAt = "created_at"
TenantUpdatedAt = "updated_at"
)

// TenantAllColumns returns all tenant columns for SELECT queries
func TenantAllColumns() string {
return "id, name, tenant_type, phone_number, email, nid_number, address, active, created_at, updated_at"
}
34 changes: 32 additions & 2 deletions src/backend/api/internal/models/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,42 @@ type Tenant struct {
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

// TenantResponse represents the API response for a tenant
type TenantResponse struct {
ID int `json:"id"`
Name string `json:"name"`
TenantType TenantType `json:"tenant_type"`
PhoneNumber string `json:"phone_number"`
Email string `json:"email"`
NIDNumber string `json:"nid_number"`
Address string `json:"address"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// ToResponse converts a Tenant model to a TenantResponse DTO
func (t *Tenant) ToResponse() *TenantResponse {
return &TenantResponse{
ID: t.ID,
Name: t.Name,
TenantType: t.TenantType,
PhoneNumber: t.PhoneNumber,
Email: t.Email,
NIDNumber: t.NIDNumber,
Address: t.Address,
Active: t.Active,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
}

type CreateTenantRequest struct {
Name string `json:"name" binding:"required,max=100"`
TenantType TenantType `json:"tenant_type" binding:"required,oneof=Individual Business"`
PhoneNumber string `json:"phone_number" binding:"omitempty,max=20"`
PhoneNumber string `json:"phone_number" binding:"required,max=20"`
Email string `json:"email" binding:"omitempty,email"`
NIDNumber string `json:"nid_number" binding:"omitempty,max=20"`
NIDNumber string `json:"nid_number" binding:"required,max=20"`
Address string `json:"address" binding:"omitempty"`
}

Expand Down
143 changes: 143 additions & 0 deletions src/backend/api/internal/repositories/tenant_repository.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package repositories

import (
"database/sql"
"fmt"

"github.com/ysnarafat/tenantly/internal/models"
"github.com/ysnarafat/tenantly/internal/models/columns"
)

// TenantRepository implements the TenantRepositoryInterface
type TenantRepository struct {
db *sql.DB
}

// NewTenantRepository creates a new TenantRepository
func NewTenantRepository(db *sql.DB) *TenantRepository {
return &TenantRepository{db: db}
}

// Create creates a new tenant
func (r *TenantRepository) Create(req *models.CreateTenantRequest) (*models.Tenant, error) {
query := fmt.Sprintf(`
INSERT INTO %s (
%s, %s, %s, %s, %s, %s, %s
) VALUES ($1, $2, $3, $4, $5, $6, true)
RETURNING %s, %s, %s`,
columns.TenantTable,
columns.TenantName, columns.TenantType, columns.TenantPhoneNumber,
columns.TenantEmail, columns.TenantNIDNumber, columns.TenantAddress, columns.TenantActive,
columns.TenantID, columns.TenantCreatedAt, columns.TenantUpdatedAt)

tenant := &models.Tenant{
Name: req.Name,
TenantType: req.TenantType,
PhoneNumber: req.PhoneNumber,
Email: req.Email,
NIDNumber: req.NIDNumber,
Address: req.Address,
Active: true,
}

err := r.db.QueryRow(
query,
tenant.Name,
tenant.TenantType,
tenant.PhoneNumber,
tenant.Email,
tenant.NIDNumber,
tenant.Address,
).Scan(&tenant.ID, &tenant.CreatedAt, &tenant.UpdatedAt)

if err != nil {
return nil, fmt.Errorf("failed to create tenant: %w", err)
}

return tenant, nil
}

// CheckEmailExists checks if an email already exists for another tenant
func (r *TenantRepository) CheckEmailExists(email string, excludeID int) (bool, error) {
if email == "" {
return false, nil
}

query := fmt.Sprintf(`
SELECT EXISTS(
SELECT 1 FROM %s
WHERE %s = $1 AND %s != $2 AND %s = true
)`,
columns.TenantTable,
columns.TenantEmail, columns.TenantID, columns.TenantActive)

var exists bool
err := r.db.QueryRow(query, email, excludeID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("failed to check email existence: %w", err)
}

return exists, nil
}

// CheckNIDExists checks if a NID number already exists for another tenant
func (r *TenantRepository) CheckNIDExists(nid string, excludeID int) (bool, error) {
if nid == "" {
return false, nil
}

query := fmt.Sprintf(`
SELECT EXISTS(
SELECT 1 FROM %s
WHERE %s = $1 AND %s != $2 AND %s = true
)`,
columns.TenantTable,
columns.TenantNIDNumber, columns.TenantID, columns.TenantActive)

var exists bool
err := r.db.QueryRow(query, nid, excludeID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("failed to check NID existence: %w", err)
}

return exists, nil
}

// GetByID retrieves a tenant by ID
func (r *TenantRepository) GetByID(id int) (*models.Tenant, error) {
query := fmt.Sprintf(`
SELECT %s
FROM %s
WHERE %s = $1 AND %s = true`,
columns.TenantAllColumns(),
columns.TenantTable,
columns.TenantID, columns.TenantActive)

tenant := &models.Tenant{}
err := r.db.QueryRow(query, id).Scan(
&tenant.ID,
&tenant.Name,
&tenant.TenantType,
&tenant.PhoneNumber,
&tenant.Email,
&tenant.NIDNumber,
&tenant.Address,
&tenant.Active,
&tenant.CreatedAt,
&tenant.UpdatedAt,
)

if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("tenant not found")
}
return nil, fmt.Errorf("failed to get tenant: %w", err)
}

return tenant, nil
}

// GetByUnitID retrieves a tenant by unit ID (will be implemented with lease functionality)
func (r *TenantRepository) GetByUnitID(unitID int) (*models.Tenant, error) {
return nil, fmt.Errorf("not implemented yet")
}
Loading