diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0c2a507..82740c8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,10 @@ import { AssetCategoriesModule } from './asset-categories/asset-categories.modul import { AssetCategory } from './asset-categories/asset-category.entity'; import { DepartmentsModule } from './departments/departments.module'; import { Department } from './departments/department.entity'; +import { CompaniesModule } from './companies/companies.module'; +import { Company } from './companies/entities/company.entity'; +import { BranchesModule } from './branches/branches.module'; +import { Branch } from './branches/entities/branch.entity'; @Module({ imports: [ @@ -38,13 +42,15 @@ import { Department } from './departments/department.entity'; username: configService.get('DB_USERNAME', 'postgres'), password: configService.get('DB_PASSWORD', 'password'), database: configService.get('DB_DATABASE', 'manage_assets'), - entities: [AssetCategory, Department], + entities: [AssetCategory, Department, Company, Branch], synchronize: configService.get('NODE_ENV') !== 'production', // Only for development }), inject: [ConfigService], }), AssetCategoriesModule, DepartmentsModule, + CompaniesModule, + BranchesModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/branches/branches.controller.ts b/backend/src/branches/branches.controller.ts new file mode 100644 index 0000000..1376f41 --- /dev/null +++ b/backend/src/branches/branches.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe } from '@nestjs/common'; +import { BranchesService } from './branches.service'; +import { CreateBranchDto } from './dto/create-branch.dto'; +import { UpdateBranchDto } from './dto/update-branch.dto'; + +@Controller('branches') +export class BranchesController { + constructor(private readonly branchesService: BranchesService) {} + + @Post() + create(@Body() createBranchDto: CreateBranchDto) { + return this.branchesService.create(createBranchDto); + } + + @Get() + findAll() { + return this.branchesService.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.branchesService.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateBranchDto: UpdateBranchDto, + ) { + return this.branchesService.update(id, updateBranchDto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.branchesService.remove(id); + } +} + + diff --git a/backend/src/branches/branches.module.ts b/backend/src/branches/branches.module.ts new file mode 100644 index 0000000..0691ad6 --- /dev/null +++ b/backend/src/branches/branches.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BranchesService } from './branches.service'; +import { BranchesController } from './branches.controller'; +import { Branch } from './entities/branch.entity'; +import { Company } from '../companies/entities/company.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Branch, Company])], + controllers: [BranchesController], + providers: [BranchesService], + exports: [BranchesService], +}) +export class BranchesModule {} + + diff --git a/backend/src/branches/branches.service.spec.ts b/backend/src/branches/branches.service.spec.ts new file mode 100644 index 0000000..692858f --- /dev/null +++ b/backend/src/branches/branches.service.spec.ts @@ -0,0 +1,146 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BranchesService } from './branches.service'; +import { Branch } from './entities/branch.entity'; +import { CreateBranchDto } from './dto/create-branch.dto'; +import { UpdateBranchDto } from './dto/update-branch.dto'; + +describe('BranchesService', () => { + let service: BranchesService; + let repository: Repository; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BranchesService, + { + provide: getRepositoryToken(Branch), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(BranchesService); + repository = module.get>(getRepositoryToken(Branch)); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + expect(repository).toBeDefined(); + }); + + describe('create', () => { + it('should create a new branch', async () => { + const dto: CreateBranchDto = { + name: 'Main Branch', + address: '123 Street', + companyId: 1, + }; + + const expected: Branch = { + id: 1, + ...dto, + createdAt: new Date(), + updatedAt: new Date(), + } as Branch; + + mockRepository.create.mockReturnValue(expected); + mockRepository.save.mockResolvedValue(expected); + + const result = await service.create(dto); + expect(mockRepository.create).toHaveBeenCalledWith(dto); + expect(mockRepository.save).toHaveBeenCalledWith(expected); + expect(result).toEqual(expected); + }); + }); + + describe('findAll', () => { + it('should return an array of branches', async () => { + const expected: Branch[] = [ + { + id: 1, + name: 'Main Branch', + address: '123 Street', + companyId: 1, + createdAt: new Date(), + updatedAt: new Date(), + } as Branch, + ]; + mockRepository.find.mockResolvedValue(expected); + const result = await service.findAll(); + expect(mockRepository.find).toHaveBeenCalled(); + expect(result).toEqual(expected); + }); + }); + + describe('findOne', () => { + it('should return a branch when found', async () => { + const expected: Branch = { + id: 1, + name: 'Main Branch', + address: '123 Street', + companyId: 1, + createdAt: new Date(), + updatedAt: new Date(), + } as Branch; + mockRepository.findOne.mockResolvedValue(expected); + const result = await service.findOne(1); + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(result).toEqual(expected); + }); + + it('should throw when branch not found', async () => { + mockRepository.findOne.mockResolvedValue(undefined); + await expect(service.findOne(999)).rejects.toThrow('Branch 999 not found'); + }); + }); + + describe('update', () => { + it('should merge and save updates', async () => { + const existing: Branch = { + id: 1, + name: 'Main Branch', + address: '123 Street', + companyId: 1, + createdAt: new Date(), + updatedAt: new Date(), + } as Branch; + const updates: UpdateBranchDto = { address: '456 Ave' }; + const saved = { ...existing, ...updates } as Branch; + mockRepository.findOne.mockResolvedValue(existing); + mockRepository.save.mockResolvedValue(saved); + const result = await service.update(1, updates); + expect(mockRepository.save).toHaveBeenCalledWith(saved); + expect(result).toEqual(saved); + }); + }); + + describe('remove', () => { + it('should remove the branch', async () => { + const existing: Branch = { + id: 1, + name: 'Main Branch', + address: '123 Street', + companyId: 1, + createdAt: new Date(), + updatedAt: new Date(), + } as Branch; + mockRepository.findOne.mockResolvedValue(existing); + mockRepository.remove.mockResolvedValue(existing); + await service.remove(1); + expect(mockRepository.remove).toHaveBeenCalledWith(existing); + }); + }); +}); + + diff --git a/backend/src/branches/branches.service.ts b/backend/src/branches/branches.service.ts new file mode 100644 index 0000000..6544aa5 --- /dev/null +++ b/backend/src/branches/branches.service.ts @@ -0,0 +1,44 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Branch } from './entities/branch.entity'; +import { CreateBranchDto } from './dto/create-branch.dto'; +import { UpdateBranchDto } from './dto/update-branch.dto'; + +@Injectable() +export class BranchesService { + constructor( + @InjectRepository(Branch) + private readonly branchRepository: Repository, + ) {} + + async create(createDto: CreateBranchDto): Promise { + const branch = this.branchRepository.create(createDto); + return await this.branchRepository.save(branch); + } + + async findAll(): Promise { + return await this.branchRepository.find(); + } + + async findOne(id: number): Promise { + const branch = await this.branchRepository.findOne({ where: { id } }); + if (!branch) { + throw new NotFoundException(`Branch ${id} not found`); + } + return branch; + } + + async update(id: number, updateDto: UpdateBranchDto): Promise { + const branch = await this.findOne(id); + Object.assign(branch, updateDto); + return await this.branchRepository.save(branch); + } + + async remove(id: number): Promise { + const branch = await this.findOne(id); + await this.branchRepository.remove(branch); + } +} + + diff --git a/backend/src/branches/dto/create-branch.dto.ts b/backend/src/branches/dto/create-branch.dto.ts new file mode 100644 index 0000000..f348f0e --- /dev/null +++ b/backend/src/branches/dto/create-branch.dto.ts @@ -0,0 +1,19 @@ +import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateBranchDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @IsString() + @IsOptional() + @MaxLength(255) + address?: string; + + @IsInt() + @IsNotEmpty() + companyId: number; +} + + diff --git a/backend/src/branches/dto/update-branch.dto.ts b/backend/src/branches/dto/update-branch.dto.ts new file mode 100644 index 0000000..b22bdcb --- /dev/null +++ b/backend/src/branches/dto/update-branch.dto.ts @@ -0,0 +1,19 @@ +import { IsInt, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateBranchDto { + @IsString() + @IsOptional() + @MaxLength(255) + name?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + address?: string; + + @IsInt() + @IsOptional() + companyId?: number; +} + + diff --git a/backend/src/branches/entities/branch.entity.ts b/backend/src/branches/entities/branch.entity.ts new file mode 100644 index 0000000..90da617 --- /dev/null +++ b/backend/src/branches/entities/branch.entity.ts @@ -0,0 +1,28 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm'; +import { Company } from '../../companies/entities/company.entity'; + +@Entity('branches') +export class Branch { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + address: string; + + @Column({ type: 'int' }) + companyId: number; + + @ManyToOne(() => Company) + company: Company; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} + + diff --git a/backend/src/companies/companies.controller.ts b/backend/src/companies/companies.controller.ts new file mode 100644 index 0000000..e5805c2 --- /dev/null +++ b/backend/src/companies/companies.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe } from '@nestjs/common'; +import { CompaniesService } from './companies.service'; +import { CreateCompanyDto } from './dto/create-company.dto'; +import { UpdateCompanyDto } from './dto/update-company.dto'; + +@Controller('companies') +export class CompaniesController { + constructor(private readonly companiesService: CompaniesService) {} + + @Post() + create(@Body() createCompanyDto: CreateCompanyDto) { + return this.companiesService.create(createCompanyDto); + } + + @Get() + findAll() { + return this.companiesService.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.companiesService.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateCompanyDto: UpdateCompanyDto, + ) { + return this.companiesService.update(id, updateCompanyDto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.companiesService.remove(id); + } +} + + diff --git a/backend/src/companies/companies.module.ts b/backend/src/companies/companies.module.ts new file mode 100644 index 0000000..6bf45e2 --- /dev/null +++ b/backend/src/companies/companies.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CompaniesService } from './companies.service'; +import { CompaniesController } from './companies.controller'; +import { Company } from './entities/company.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Company])], + controllers: [CompaniesController], + providers: [CompaniesService], + exports: [CompaniesService], +}) +export class CompaniesModule {} + + diff --git a/backend/src/companies/companies.service.spec.ts b/backend/src/companies/companies.service.spec.ts new file mode 100644 index 0000000..e00ef6f --- /dev/null +++ b/backend/src/companies/companies.service.spec.ts @@ -0,0 +1,156 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CompaniesService } from './companies.service'; +import { Company } from './entities/company.entity'; +import { CreateCompanyDto } from './dto/create-company.dto'; +import { UpdateCompanyDto } from './dto/update-company.dto'; + +describe('CompaniesService', () => { + let service: CompaniesService; + let repository: Repository; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CompaniesService, + { + provide: getRepositoryToken(Company), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(CompaniesService); + repository = module.get>(getRepositoryToken(Company)); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + expect(repository).toBeDefined(); + }); + + describe('create', () => { + it('should create a new company', async () => { + const dto: CreateCompanyDto = { + name: 'Acme Inc', + country: 'US', + registrationNumber: 'REG-123', + }; + + const expected: Company = { + id: 1, + ...dto, + createdAt: new Date(), + updatedAt: new Date(), + } as Company; + + mockRepository.create.mockReturnValue(expected); + mockRepository.save.mockResolvedValue(expected); + + const result = await service.create(dto); + + expect(mockRepository.create).toHaveBeenCalledWith(dto); + expect(mockRepository.save).toHaveBeenCalledWith(expected); + expect(result).toEqual(expected); + }); + }); + + describe('findAll', () => { + it('should return an array of companies', async () => { + const expected: Company[] = [ + { + id: 1, + name: 'Acme Inc', + country: 'US', + registrationNumber: 'REG-123', + createdAt: new Date(), + updatedAt: new Date(), + } as Company, + ]; + + mockRepository.find.mockResolvedValue(expected); + + const result = await service.findAll(); + expect(mockRepository.find).toHaveBeenCalled(); + expect(result).toEqual(expected); + }); + }); + + describe('findOne', () => { + it('should return a company when found', async () => { + const expected: Company = { + id: 1, + name: 'Acme Inc', + country: 'US', + registrationNumber: 'REG-123', + createdAt: new Date(), + updatedAt: new Date(), + } as Company; + + mockRepository.findOne.mockResolvedValue(expected); + + const result = await service.findOne(1); + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(result).toEqual(expected); + }); + + it('should throw when company not found', async () => { + mockRepository.findOne.mockResolvedValue(undefined); + await expect(service.findOne(999)).rejects.toThrow('Company 999 not found'); + }); + }); + + describe('update', () => { + it('should merge and save updates', async () => { + const existing: Company = { + id: 1, + name: 'Acme Inc', + country: 'US', + registrationNumber: 'REG-123', + createdAt: new Date(), + updatedAt: new Date(), + } as Company; + + const updates: UpdateCompanyDto = { country: 'CA' }; + const saved = { ...existing, ...updates } as Company; + + mockRepository.findOne.mockResolvedValue(existing); + mockRepository.save.mockResolvedValue(saved); + + const result = await service.update(1, updates); + expect(mockRepository.save).toHaveBeenCalledWith(saved); + expect(result).toEqual(saved); + }); + }); + + describe('remove', () => { + it('should remove the company', async () => { + const existing: Company = { + id: 1, + name: 'Acme Inc', + country: 'US', + registrationNumber: 'REG-123', + createdAt: new Date(), + updatedAt: new Date(), + } as Company; + + mockRepository.findOne.mockResolvedValue(existing); + mockRepository.remove.mockResolvedValue(existing); + + await service.remove(1); + expect(mockRepository.remove).toHaveBeenCalledWith(existing); + }); + }); +}); + + diff --git a/backend/src/companies/companies.service.ts b/backend/src/companies/companies.service.ts new file mode 100644 index 0000000..5495311 --- /dev/null +++ b/backend/src/companies/companies.service.ts @@ -0,0 +1,44 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Company } from './entities/company.entity'; +import { CreateCompanyDto } from './dto/create-company.dto'; +import { UpdateCompanyDto } from './dto/update-company.dto'; + +@Injectable() +export class CompaniesService { + constructor( + @InjectRepository(Company) + private readonly companyRepository: Repository, + ) {} + + async create(createDto: CreateCompanyDto): Promise { + const company = this.companyRepository.create(createDto); + return await this.companyRepository.save(company); + } + + async findAll(): Promise { + return await this.companyRepository.find(); + } + + async findOne(id: number): Promise { + const company = await this.companyRepository.findOne({ where: { id } }); + if (!company) { + throw new NotFoundException(`Company ${id} not found`); + } + return company; + } + + async update(id: number, updateDto: UpdateCompanyDto): Promise { + const company = await this.findOne(id); + Object.assign(company, updateDto); + return await this.companyRepository.save(company); + } + + async remove(id: number): Promise { + const company = await this.findOne(id); + await this.companyRepository.remove(company); + } +} + + diff --git a/backend/src/companies/dto/create-company.dto.ts b/backend/src/companies/dto/create-company.dto.ts new file mode 100644 index 0000000..b0395e5 --- /dev/null +++ b/backend/src/companies/dto/create-company.dto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class CreateCompanyDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + name: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + country: string; + + @IsString() + @IsNotEmpty() + @MaxLength(150) + registrationNumber: string; +} + + diff --git a/backend/src/companies/dto/update-company.dto.ts b/backend/src/companies/dto/update-company.dto.ts new file mode 100644 index 0000000..5d6173e --- /dev/null +++ b/backend/src/companies/dto/update-company.dto.ts @@ -0,0 +1,20 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateCompanyDto { + @IsString() + @IsOptional() + @MaxLength(255) + name?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + country?: string; + + @IsString() + @IsOptional() + @MaxLength(150) + registrationNumber?: string; +} + + diff --git a/backend/src/companies/entities/company.entity.ts b/backend/src/companies/entities/company.entity.ts new file mode 100644 index 0000000..5ee6892 --- /dev/null +++ b/backend/src/companies/entities/company.entity.ts @@ -0,0 +1,24 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('companies') +export class Company { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'varchar', length: 100 }) + country: string; + + @Column({ type: 'varchar', length: 150, unique: true }) + registrationNumber: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} + + diff --git a/backend/src/departments/department.entity.ts b/backend/src/departments/department.entity.ts index 4018633..09fbcd2 100644 --- a/backend/src/departments/department.entity.ts +++ b/backend/src/departments/department.entity.ts @@ -1,4 +1,5 @@ import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Company } from '../companies/entities/company.entity'; @Entity('departments') export class Department { @@ -20,9 +21,9 @@ export class Department { @UpdateDateColumn() updatedAt: Date; - // Relationships (commented out until related entities are created) - // @ManyToOne(() => Company, company => company.departments) - // company: Company; + // Relationships + @ManyToOne(() => Company) + company: Company; // @OneToMany(() => User, user => user.department) // users: User[];