Bluewoo HRMS
AI Development GuideEntity References

Reference - Tenant Implementation

Complete NestJS tenant module implementation for copy-paste

Reference: Tenant Implementation

This is a complete, copy-paste ready implementation of the Tenant module. Use this as the reference pattern for all other modules.

Module Structure

apps/api/src/modules/tenant/
├── tenant.module.ts
├── tenant.controller.ts
├── tenant.repository.ts
├── dto/
│   ├── create-tenant.dto.ts
│   ├── update-tenant.dto.ts
│   └── tenant-response.dto.ts
└── tenant.controller.spec.ts

Tenant Module

// tenant.module.ts
import { Module } from '@nestjs/common';
import { TenantController } from './tenant.controller';
import { TenantRepository } from './tenant.repository';
import { PrismaModule } from '../../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [TenantController],
  providers: [TenantRepository],
  exports: [TenantRepository],
})
export class TenantModule {}

Tenant Controller

// tenant.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  ParseUUIDPipe,
} from '@nestjs/common';
import { TenantRepository } from './tenant.repository';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';
import { TenantResponseDto } from './dto/tenant-response.dto';
import { RequirePermissions } from '../../common/decorators/permissions.decorator';
import { PlatformAdminOnly } from '../../common/decorators/platform-admin.decorator';

@Controller('tenants')
export class TenantController {
  constructor(private readonly tenantRepository: TenantRepository) {}

  /**
   * List all tenants (Platform Admin only)
   */
  @Get()
  @PlatformAdminOnly()
  async findAll(
    @Query('page') page: number = 1,
    @Query('limit') limit: number = 20,
    @Query('search') search?: string,
    @Query('status') status?: string,
  ): Promise<{ data: TenantResponseDto[]; total: number; page: number; limit: number }> {
    const { tenants, total } = await this.tenantRepository.findAll({
      page,
      limit,
      search,
      status,
    });

    return {
      data: tenants.map((tenant) => new TenantResponseDto(tenant)),
      total,
      page,
      limit,
    };
  }

  /**
   * Get single tenant by ID
   */
  @Get(':id')
  @PlatformAdminOnly()
  async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<TenantResponseDto> {
    const tenant = await this.tenantRepository.findById(id);
    return new TenantResponseDto(tenant);
  }

  /**
   * Create new tenant (Platform Admin only)
   */
  @Post()
  @PlatformAdminOnly()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() createTenantDto: CreateTenantDto): Promise<TenantResponseDto> {
    const tenant = await this.tenantRepository.create(createTenantDto);
    return new TenantResponseDto(tenant);
  }

  /**
   * Update tenant
   */
  @Put(':id')
  @PlatformAdminOnly()
  async update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateTenantDto: UpdateTenantDto,
  ): Promise<TenantResponseDto> {
    const tenant = await this.tenantRepository.update(id, updateTenantDto);
    return new TenantResponseDto(tenant);
  }

  /**
   * Delete tenant (soft delete by setting status to SUSPENDED)
   */
  @Delete(':id')
  @PlatformAdminOnly()
  @HttpCode(HttpStatus.NO_CONTENT)
  async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
    await this.tenantRepository.softDelete(id);
  }

  /**
   * Get tenant by domain (used for login)
   */
  @Get('domain/:domain')
  async findByDomain(@Param('domain') domain: string): Promise<TenantResponseDto> {
    const tenant = await this.tenantRepository.findByDomain(domain);
    return new TenantResponseDto(tenant);
  }
}

Tenant Repository

// tenant.repository.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Tenant, TenantStatus, Prisma } from '@prisma/client';
import { CreateTenantDto } from './dto/create-tenant.dto';
import { UpdateTenantDto } from './dto/update-tenant.dto';

interface FindAllOptions {
  page: number;
  limit: number;
  search?: string;
  status?: string;
}

@Injectable()
export class TenantRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findAll(options: FindAllOptions): Promise<{ tenants: Tenant[]; total: number }> {
    const { page, limit, search, status } = options;
    const skip = (page - 1) * limit;

    const where: Prisma.TenantWhereInput = {};

    if (search) {
      where.OR = [
        { name: { contains: search, mode: 'insensitive' } },
        { domain: { contains: search, mode: 'insensitive' } },
      ];
    }

    if (status) {
      where.status = status as TenantStatus;
    }

    const [tenants, total] = await this.prisma.$transaction([
      this.prisma.tenant.findMany({
        where,
        skip,
        take: limit,
        orderBy: { createdAt: 'desc' },
      }),
      this.prisma.tenant.count({ where }),
    ]);

    return { tenants, total };
  }

  async findById(id: string): Promise<Tenant> {
    const tenant = await this.prisma.tenant.findUnique({
      where: { id },
    });

    if (!tenant) {
      throw new NotFoundException(`Tenant with ID ${id} not found`);
    }

    return tenant;
  }

  async findByDomain(domain: string): Promise<Tenant> {
    const tenant = await this.prisma.tenant.findUnique({
      where: { domain },
    });

    if (!tenant) {
      throw new NotFoundException(`Tenant with domain ${domain} not found`);
    }

    return tenant;
  }

  async create(dto: CreateTenantDto): Promise<Tenant> {
    // Check for existing domain
    if (dto.domain) {
      const existing = await this.prisma.tenant.findUnique({
        where: { domain: dto.domain },
      });

      if (existing) {
        throw new ConflictException(`Tenant with domain ${dto.domain} already exists`);
      }
    }

    return this.prisma.tenant.create({
      data: {
        name: dto.name,
        domain: dto.domain,
        status: dto.status || 'TRIAL',
        settings: dto.settings || {},
      },
    });
  }

  async update(id: string, dto: UpdateTenantDto): Promise<Tenant> {
    // Verify tenant exists
    await this.findById(id);

    // Check for domain conflict if updating domain
    if (dto.domain) {
      const existing = await this.prisma.tenant.findFirst({
        where: {
          domain: dto.domain,
          NOT: { id },
        },
      });

      if (existing) {
        throw new ConflictException(`Tenant with domain ${dto.domain} already exists`);
      }
    }

    return this.prisma.tenant.update({
      where: { id },
      data: {
        ...(dto.name && { name: dto.name }),
        ...(dto.domain && { domain: dto.domain }),
        ...(dto.status && { status: dto.status }),
        ...(dto.settings && { settings: dto.settings }),
      },
    });
  }

  async softDelete(id: string): Promise<Tenant> {
    // Verify tenant exists
    await this.findById(id);

    return this.prisma.tenant.update({
      where: { id },
      data: { status: 'SUSPENDED' },
    });
  }

  async hardDelete(id: string): Promise<void> {
    await this.prisma.tenant.delete({
      where: { id },
    });
  }
}

DTOs

Create Tenant DTO

// dto/create-tenant.dto.ts
import { IsString, IsOptional, IsEnum, IsObject, MinLength, MaxLength, Matches } from 'class-validator';
import { TenantStatus } from '@prisma/client';

export class CreateTenantDto {
  @IsString()
  @MinLength(2)
  @MaxLength(100)
  name: string;

  @IsOptional()
  @IsString()
  @Matches(/^[a-z0-9-]+(\.[a-z0-9-]+)*$/, {
    message: 'Domain must be lowercase alphanumeric with optional hyphens and dots',
  })
  domain?: string;

  @IsOptional()
  @IsEnum(TenantStatus)
  status?: TenantStatus;

  @IsOptional()
  @IsObject()
  settings?: Record<string, any>;
}

Update Tenant DTO

// dto/update-tenant.dto.ts
import { IsString, IsOptional, IsEnum, IsObject, MinLength, MaxLength, Matches } from 'class-validator';
import { TenantStatus } from '@prisma/client';

export class UpdateTenantDto {
  @IsOptional()
  @IsString()
  @MinLength(2)
  @MaxLength(100)
  name?: string;

  @IsOptional()
  @IsString()
  @Matches(/^[a-z0-9-]+(\.[a-z0-9-]+)*$/, {
    message: 'Domain must be lowercase alphanumeric with optional hyphens and dots',
  })
  domain?: string;

  @IsOptional()
  @IsEnum(TenantStatus)
  status?: TenantStatus;

  @IsOptional()
  @IsObject()
  settings?: Record<string, any>;
}

Tenant Response DTO

// dto/tenant-response.dto.ts
import { Tenant, TenantStatus } from '@prisma/client';

export class TenantResponseDto {
  id: string;
  name: string;
  domain: string | null;
  status: TenantStatus;
  settings: Record<string, any>;
  createdAt: Date;
  updatedAt: Date;

  constructor(tenant: Tenant) {
    this.id = tenant.id;
    this.name = tenant.name;
    this.domain = tenant.domain;
    this.status = tenant.status;
    this.settings = tenant.settings as Record<string, any>;
    this.createdAt = tenant.createdAt;
    this.updatedAt = tenant.updatedAt;
  }
}

Unit Test

// tenant.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TenantController } from './tenant.controller';
import { TenantRepository } from './tenant.repository';
import { NotFoundException } from '@nestjs/common';

describe('TenantController', () => {
  let controller: TenantController;
  let repository: TenantRepository;

  const mockTenant = {
    id: 'tenant-123',
    name: 'Test Company',
    domain: 'test.hrms.local',
    status: 'ACTIVE',
    settings: {},
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  const mockTenantRepository = {
    findAll: jest.fn(),
    findById: jest.fn(),
    findByDomain: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    softDelete: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [TenantController],
      providers: [
        {
          provide: TenantRepository,
          useValue: mockTenantRepository,
        },
      ],
    }).compile();

    controller = module.get<TenantController>(TenantController);
    repository = module.get<TenantRepository>(TenantRepository);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('findAll', () => {
    it('should return paginated tenants', async () => {
      mockTenantRepository.findAll.mockResolvedValue({
        tenants: [mockTenant],
        total: 1,
      });

      const result = await controller.findAll(1, 20);

      expect(result.data).toHaveLength(1);
      expect(result.total).toBe(1);
      expect(result.page).toBe(1);
      expect(result.limit).toBe(20);
    });
  });

  describe('findOne', () => {
    it('should return a tenant by ID', async () => {
      mockTenantRepository.findById.mockResolvedValue(mockTenant);

      const result = await controller.findOne('tenant-123');

      expect(result.id).toBe('tenant-123');
      expect(result.name).toBe('Test Company');
    });

    it('should throw NotFoundException if tenant not found', async () => {
      mockTenantRepository.findById.mockRejectedValue(
        new NotFoundException('Tenant not found'),
      );

      await expect(controller.findOne('invalid-id')).rejects.toThrow(NotFoundException);
    });
  });

  describe('create', () => {
    it('should create a new tenant', async () => {
      const createDto = { name: 'New Company', domain: 'new.hrms.local' };
      mockTenantRepository.create.mockResolvedValue({
        ...mockTenant,
        ...createDto,
      });

      const result = await controller.create(createDto);

      expect(result.name).toBe('New Company');
      expect(mockTenantRepository.create).toHaveBeenCalledWith(createDto);
    });
  });

  describe('update', () => {
    it('should update a tenant', async () => {
      const updateDto = { name: 'Updated Company' };
      mockTenantRepository.update.mockResolvedValue({
        ...mockTenant,
        name: 'Updated Company',
      });

      const result = await controller.update('tenant-123', updateDto);

      expect(result.name).toBe('Updated Company');
    });
  });

  describe('delete', () => {
    it('should soft delete a tenant', async () => {
      mockTenantRepository.softDelete.mockResolvedValue(undefined);

      await controller.delete('tenant-123');

      expect(mockTenantRepository.softDelete).toHaveBeenCalledWith('tenant-123');
    });
  });
});

Platform Admin Guard

The @PlatformAdminOnly() decorator restricts endpoints to SYSTEM_ADMIN users only:

// common/guards/platform-admin.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { UseGuards } from '@nestjs/common';

@Injectable()
export class PlatformAdminGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user) {
      throw new ForbiddenException('Authentication required');
    }

    if (user.systemRole !== 'SYSTEM_ADMIN') {
      throw new ForbiddenException('Platform Admin access required');
    }

    return true;
  }
}

// Decorator for cleaner usage
export const PlatformAdminOnly = () => UseGuards(PlatformAdminGuard);

Usage:

  • Apply @PlatformAdminOnly() to controllers or individual endpoints
  • Works independently of tenant context (no X-Tenant-ID required)
  • Returns 403 Forbidden for non-SYSTEM_ADMIN users

Assign Tenant Admin

Add this endpoint to assign an admin user to a tenant (Phase 10):

// In TenantController - add this endpoint
@Post(':id/assign-admin')
@PlatformAdminOnly()
@HttpCode(HttpStatus.CREATED)
async assignAdmin(
  @Param('id', ParseUUIDPipe) tenantId: string,
  @Body() dto: AssignAdminDto,
): Promise<UserResponseDto> {
  return this.tenantRepository.assignAdmin(tenantId, dto);
}
// dto/assign-admin.dto.ts
import { IsString, IsEmail, MinLength, MaxLength } from 'class-validator';

export class AssignAdminDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(1)
  @MaxLength(100)
  name: string;
}
// In TenantRepository - add this method
async assignAdmin(tenantId: string, dto: AssignAdminDto): Promise<User> {
  // Verify tenant exists
  await this.findById(tenantId);

  // Check if email already exists
  const existingUser = await this.prisma.user.findUnique({
    where: { email: dto.email },
  });

  if (existingUser) {
    throw new ConflictException('User with this email already exists');
  }

  // Create user with HR_ADMIN role (no password - forces password reset)
  return this.prisma.user.create({
    data: {
      email: dto.email,
      name: dto.name,
      tenantId: tenantId,
      systemRole: 'HR_ADMIN',
      // password is null - user must use forgot-password flow
    },
  });
}

Note: The assigned admin has no password set. They must use the "Forgot Password" flow to set their password on first login.


TenantDomain Model

Add this model to support multiple email domains per tenant for domain-based registration:

model TenantDomain {
  id        String   @id @default(cuid())
  tenantId  String
  domain    String   @unique  // e.g., "capptoo.com"
  verified  Boolean  @default(false)
  isPrimary Boolean  @default(false)
  createdAt DateTime @default(now())

  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)

  @@index([tenantId])
  @@map("tenant_domains")
}

Domain Rules:

  • One domain can only belong to one tenant (unique constraint)
  • A tenant can have multiple domains (e.g., capptoo.com, capptoo.ch)
  • Primary domain is used for display/identification
  • Verified flag for future domain verification feature

Domain Management Endpoints

Add these endpoints to manage tenant domains (Phase 10, Steps 07-10):

// In TenantController - add these domain management endpoints

/**
 * List domains for a tenant
 */
@Get(':id/domains')
@PlatformAdminOnly()
async listDomains(
  @Param('id', ParseUUIDPipe) tenantId: string,
): Promise<TenantDomainResponseDto[]> {
  return this.tenantRepository.listDomains(tenantId);
}

/**
 * Add domain to a tenant
 */
@Post(':id/domains')
@PlatformAdminOnly()
@HttpCode(HttpStatus.CREATED)
async addDomain(
  @Param('id', ParseUUIDPipe) tenantId: string,
  @Body() dto: AddDomainDto,
): Promise<TenantDomainResponseDto> {
  return this.tenantRepository.addDomain(tenantId, dto);
}

/**
 * Remove domain from a tenant
 */
@Delete(':id/domains/:domainId')
@PlatformAdminOnly()
@HttpCode(HttpStatus.NO_CONTENT)
async removeDomain(
  @Param('id', ParseUUIDPipe) tenantId: string,
  @Param('domainId', ParseUUIDPipe) domainId: string,
): Promise<void> {
  await this.tenantRepository.removeDomain(tenantId, domainId);
}

/**
 * Update domain (set as primary)
 */
@Patch(':id/domains/:domainId')
@PlatformAdminOnly()
async updateDomain(
  @Param('id', ParseUUIDPipe) tenantId: string,
  @Param('domainId', ParseUUIDPipe) domainId: string,
  @Body() dto: UpdateDomainDto,
): Promise<TenantDomainResponseDto> {
  return this.tenantRepository.updateDomain(tenantId, domainId, dto);
}

Domain DTOs

// dto/add-domain.dto.ts
import { IsString, IsOptional, IsBoolean, Matches } from 'class-validator';

export class AddDomainDto {
  @IsString()
  @Matches(/^[a-z0-9]+([\-\.][a-z0-9]+)*\.[a-z]{2,}$/, {
    message: 'Domain must be lowercase, no protocol (e.g., capptoo.com)',
  })
  domain: string;

  @IsOptional()
  @IsBoolean()
  isPrimary?: boolean;
}
// dto/update-domain.dto.ts
import { IsOptional, IsBoolean } from 'class-validator';

export class UpdateDomainDto {
  @IsOptional()
  @IsBoolean()
  isPrimary?: boolean;
}
// dto/tenant-domain-response.dto.ts
import { TenantDomain } from '@prisma/client';

export class TenantDomainResponseDto {
  id: string;
  domain: string;
  verified: boolean;
  isPrimary: boolean;
  createdAt: Date;

  constructor(domain: TenantDomain) {
    this.id = domain.id;
    this.domain = domain.domain;
    this.verified = domain.verified;
    this.isPrimary = domain.isPrimary;
    this.createdAt = domain.createdAt;
  }
}

Domain Repository Methods

// In TenantRepository - add these domain management methods

async listDomains(tenantId: string): Promise<TenantDomain[]> {
  await this.findById(tenantId); // Verify tenant exists

  return this.prisma.tenantDomain.findMany({
    where: { tenantId },
    orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
  });
}

async addDomain(tenantId: string, dto: AddDomainDto): Promise<TenantDomain> {
  await this.findById(tenantId); // Verify tenant exists

  // Check if domain is already claimed
  const existing = await this.prisma.tenantDomain.findUnique({
    where: { domain: dto.domain.toLowerCase() },
  });

  if (existing) {
    throw new ConflictException(`Domain ${dto.domain} is already registered`);
  }

  // If setting as primary, unset other primary domains
  if (dto.isPrimary) {
    await this.prisma.tenantDomain.updateMany({
      where: { tenantId, isPrimary: true },
      data: { isPrimary: false },
    });
  }

  return this.prisma.tenantDomain.create({
    data: {
      tenantId,
      domain: dto.domain.toLowerCase(),
      isPrimary: dto.isPrimary || false,
    },
  });
}

async removeDomain(tenantId: string, domainId: string): Promise<void> {
  await this.findById(tenantId); // Verify tenant exists

  const domain = await this.prisma.tenantDomain.findFirst({
    where: { id: domainId, tenantId },
  });

  if (!domain) {
    throw new NotFoundException('Domain not found');
  }

  // Check if this is the last domain and tenant has users
  const [domainCount, userCount] = await this.prisma.$transaction([
    this.prisma.tenantDomain.count({ where: { tenantId } }),
    this.prisma.user.count({ where: { tenantId } }),
  ]);

  if (domainCount === 1 && userCount > 0) {
    throw new ConflictException('Cannot remove last domain when tenant has users');
  }

  await this.prisma.tenantDomain.delete({ where: { id: domainId } });
}

async updateDomain(
  tenantId: string,
  domainId: string,
  dto: UpdateDomainDto,
): Promise<TenantDomain> {
  await this.findById(tenantId); // Verify tenant exists

  const domain = await this.prisma.tenantDomain.findFirst({
    where: { id: domainId, tenantId },
  });

  if (!domain) {
    throw new NotFoundException('Domain not found');
  }

  // If setting as primary, unset other primary domains
  if (dto.isPrimary) {
    await this.prisma.tenantDomain.updateMany({
      where: { tenantId, isPrimary: true },
      data: { isPrimary: false },
    });
  }

  return this.prisma.tenantDomain.update({
    where: { id: domainId },
    data: { isPrimary: dto.isPrimary },
  });
}

async findTenantByEmailDomain(emailDomain: string): Promise<Tenant | null> {
  const tenantDomain = await this.prisma.tenantDomain.findUnique({
    where: { domain: emailDomain.toLowerCase() },
    include: { tenant: true },
  });

  return tenantDomain?.tenant || null;
}

Key Patterns

1. Controller → Repository Pattern

  • Controllers handle HTTP concerns (validation, response formatting)
  • Repositories handle data access (Prisma queries)
  • No Service layer for simple CRUD (reduces boilerplate)

2. Response DTOs

  • Transform database entities to API responses
  • Hide internal fields (e.g., password hashes)
  • Consistent response format

3. Error Handling

  • Repository throws NotFoundException for missing entities
  • Repository throws ConflictException for duplicates
  • NestJS exception filters handle the rest

4. Pagination

  • Standard query params: page, limit, search
  • Response includes data, total, page, limit

5. Soft Delete

  • Set status: SUSPENDED instead of deleting
  • Hard delete available but rarely used

Usage

Register the module in app.module.ts:

import { Module } from '@nestjs/common';
import { TenantModule } from './modules/tenant/tenant.module';

@Module({
  imports: [
    TenantModule,
    // ... other modules
  ],
})
export class AppModule {}

Next Steps