diff --git a/backend/package.json b/backend/package.json index fece9f91..9637df8d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -92,6 +92,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 619a260d..7576a4d9 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,7 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { EmailModule } from './email/email.module'; -import { SuppliersModule } from './suppliers/suppliers.module'; +import { ProcurementModule } from './procurement/procurement.module'; import { APP_GUARD } from '@nestjs/core'; import { JwtAuthGuard } from './auth/guards/jwt.guard'; import { AssetDepreciationModule } from './asset-depreciation/asset-depreciation.module'; @@ -34,10 +34,10 @@ import { AssetDepreciationModule } from './asset-depreciation/asset-depreciation : false, }), }), - AuthModule, - UsersModule, - EmailModule, - SuppliersModule, + AuthModule, + UsersModule, + EmailModule, + ProcurementModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/config/typeorm.config.ts b/backend/src/config/typeorm.config.ts index 8686c7ff..d70a975f 100644 --- a/backend/src/config/typeorm.config.ts +++ b/backend/src/config/typeorm.config.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { config } from 'dotenv'; import { User } from '../users/entities/user.entity'; import { RefreshToken } from '../auth/entities/refreshToken.entity'; +import { ProcurementRequest } from '../procurement/entities/procurement-request.entity'; config(); @@ -19,7 +20,7 @@ export default new DataSource({ configService.get('NODE_ENV') === 'production' ? { rejectUnauthorized: false } : false, - entities: [User, RefreshToken], + entities: [User, RefreshToken, ProcurementRequest], migrations: ['src/migrations/*.ts'], synchronize: false, // Always false for migrations -}); \ No newline at end of file +}); diff --git a/backend/src/migrations/1727955600000-CreateProcurementRequestsTable.ts b/backend/src/migrations/1727955600000-CreateProcurementRequestsTable.ts new file mode 100644 index 00000000..a521d233 --- /dev/null +++ b/backend/src/migrations/1727955600000-CreateProcurementRequestsTable.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +export class CreateProcurementRequestsTable1727955600000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'procurement_requests', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'itemName', type: 'varchar', length: '255', isNullable: false }, + { name: 'quantity', type: 'int', isNullable: false }, + { name: 'requestedById', type: 'uuid', isNullable: false }, + { name: 'status', type: 'varchar', isNullable: false, default: "'PENDING'" }, + { name: 'createdAt', type: 'timestamp with time zone', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp with time zone', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'procurement_requests', + new TableForeignKey({ + columnNames: ['requestedById'], + referencedColumnNames: ['id'], +referencedTableName: 'users', + onDelete: 'RESTRICT', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('procurement_requests'); + const fk = table?.foreignKeys.find((f) => f.columnNames.includes('requestedById')); + if (fk) { + await queryRunner.dropForeignKey('procurement_requests', fk); + } + await queryRunner.dropTable('procurement_requests', true); + } +} \ No newline at end of file diff --git a/backend/src/procurement/dto/create-procurement-request.dto.ts b/backend/src/procurement/dto/create-procurement-request.dto.ts new file mode 100644 index 00000000..eb8e0a5e --- /dev/null +++ b/backend/src/procurement/dto/create-procurement-request.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsNotEmpty, IsPositive, IsString, MaxLength } from 'class-validator'; + +export class CreateProcurementRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + itemName: string; + + @IsInt() + @IsPositive() + quantity: number; +} \ No newline at end of file diff --git a/backend/src/procurement/dto/update-procurement-status.dto.ts b/backend/src/procurement/dto/update-procurement-status.dto.ts new file mode 100644 index 00000000..422965d1 --- /dev/null +++ b/backend/src/procurement/dto/update-procurement-status.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { ProcurementStatus } from '../enums/procurement-status.enum'; + +export class UpdateProcurementStatusDto { + @IsEnum(ProcurementStatus) + status: ProcurementStatus; +} \ No newline at end of file diff --git a/backend/src/procurement/entities/procurement-request.entity.ts b/backend/src/procurement/entities/procurement-request.entity.ts new file mode 100644 index 00000000..b2f37453 --- /dev/null +++ b/backend/src/procurement/entities/procurement-request.entity.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { ProcurementStatus } from '../enums/procurement-status.enum'; + +@Entity('procurement_requests') +export class ProcurementRequest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 255 }) + itemName: string; + + @Column({ type: 'int' }) + quantity: number; + + @ManyToOne(() => User, { nullable: false, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'requestedById' }) + requestedBy: User; + + @Column({ type: 'enum', enum: ProcurementStatus, default: ProcurementStatus.PENDING }) + status: ProcurementStatus; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/procurement/enums/procurement-status.enum.ts b/backend/src/procurement/enums/procurement-status.enum.ts new file mode 100644 index 00000000..acb36e67 --- /dev/null +++ b/backend/src/procurement/enums/procurement-status.enum.ts @@ -0,0 +1,5 @@ +export enum ProcurementStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', +} \ No newline at end of file diff --git a/backend/src/procurement/interfaces/asset-registration.interface.ts b/backend/src/procurement/interfaces/asset-registration.interface.ts new file mode 100644 index 00000000..18cb1433 --- /dev/null +++ b/backend/src/procurement/interfaces/asset-registration.interface.ts @@ -0,0 +1,12 @@ +export const ASSET_REGISTRATION_TOKEN = Symbol('ASSET_REGISTRATION_TOKEN'); + +export interface AssetRegistrationService { + // Called when a procurement is approved to register an asset. + // Returns the created asset ID or any identifier if applicable. + registerAsset(input: { + procurementRequestId: string; + itemName: string; + quantity: number; + requestedById: string; + }): Promise; +} \ No newline at end of file diff --git a/backend/src/procurement/procurement.controller.ts b/backend/src/procurement/procurement.controller.ts new file mode 100644 index 00000000..d41123f4 --- /dev/null +++ b/backend/src/procurement/procurement.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ProcurementService } from './providers/procurement.service'; +import { CreateProcurementRequestDto } from './dto/create-procurement-request.dto'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { UserRole } from '../users/enums/userRoles.enum'; + +@Controller('procurements') +export class ProcurementController { + constructor(private readonly procurementService: ProcurementService) {} + + @Post() + async create( + @Body() dto: CreateProcurementRequestDto, + @GetCurrentUser('id') userId: string, + ) { + return await this.procurementService.createRequest(dto, userId); + } + + @Patch(':id/approve') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + async approve(@Param('id') id: string) { + return await this.procurementService.approveRequest(id); + } + + @Patch(':id/reject') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN) + async reject(@Param('id') id: string) { + return await this.procurementService.rejectRequest(id); + } +} \ No newline at end of file diff --git a/backend/src/procurement/procurement.module.ts b/backend/src/procurement/procurement.module.ts new file mode 100644 index 00000000..02d4bf1f --- /dev/null +++ b/backend/src/procurement/procurement.module.ts @@ -0,0 +1,23 @@ +import { Module, Provider } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProcurementRequest } from './entities/procurement-request.entity'; +import { ProcurementService } from './providers/procurement.service'; +import { ProcurementController } from './procurement.controller'; +import { UsersModule } from '../users/users.module'; +import { ASSET_REGISTRATION_TOKEN } from './interfaces/asset-registration.interface'; + +// Default no-op provider for asset registration; can be overridden in root module if needed +const DefaultAssetRegistrationProvider: Provider = { + provide: ASSET_REGISTRATION_TOKEN, + useValue: { + registerAsset: async () => undefined, + }, +}; + +@Module({ + imports: [TypeOrmModule.forFeature([ProcurementRequest]), UsersModule], + controllers: [ProcurementController], + providers: [ProcurementService, DefaultAssetRegistrationProvider], + exports: [ProcurementService], +}) +export class ProcurementModule {} \ No newline at end of file diff --git a/backend/src/procurement/providers/procurement.service.spec.ts b/backend/src/procurement/providers/procurement.service.spec.ts new file mode 100644 index 00000000..951a1dcc --- /dev/null +++ b/backend/src/procurement/providers/procurement.service.spec.ts @@ -0,0 +1,82 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ProcurementService } from './procurement.service'; +import { ProcurementRequest } from '../entities/procurement-request.entity'; +import { UsersService } from '../../users/providers/users.service'; +import { ProcurementStatus } from '../enums/procurement-status.enum'; +import { ASSET_REGISTRATION_TOKEN } from '../interfaces/asset-registration.interface'; + +const userStub = { id: 'user-1' } as any; + +describe('ProcurementService', () => { + let service: ProcurementService; + let repo: Repository; + let usersService: UsersService; + const assetRegistration = { registerAsset: jest.fn() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProcurementService, + { + provide: getRepositoryToken(ProcurementRequest), + useValue: { + create: jest.fn((x) => x), + save: jest.fn(async (x) => ({ id: 'req-1', ...x })), + findOne: jest.fn(), + }, + }, + { + provide: UsersService, + useValue: { + findUserById: jest.fn(async () => userStub), + }, + }, + { provide: ASSET_REGISTRATION_TOKEN, useValue: assetRegistration }, + ], + }).compile(); + + service = module.get(ProcurementService); + repo = module.get(getRepositoryToken(ProcurementRequest)); + usersService = module.get(UsersService); + }); + + it('should create a procurement request', async () => { + const dto = { itemName: 'Laptop', quantity: 2 }; + const result = await service.createRequest(dto as any, userStub.id); + expect(usersService.findUserById).toHaveBeenCalledWith(userStub.id); + expect(result.itemName).toBe('Laptop'); + expect(result.quantity).toBe(2); + expect(result.status).toBe(ProcurementStatus.PENDING); + }); + + it('should approve a procurement request and call asset registration', async () => { + (repo.findOne as any).mockResolvedValue({ + id: 'req-1', + itemName: 'Chair', + quantity: 5, + status: ProcurementStatus.PENDING, + requestedBy: userStub, + }); + + const result = await service.approveRequest('req-1'); + expect(result.status).toBe(ProcurementStatus.APPROVED); + expect(assetRegistration.registerAsset).toHaveBeenCalledWith({ + procurementRequestId: 'req-1', + itemName: 'Chair', + quantity: 5, + requestedById: 'user-1', + }); + }); + + it('should reject a procurement request', async () => { + (repo.findOne as any).mockResolvedValue({ + id: 'req-2', + status: ProcurementStatus.PENDING, + }); + + const result = await service.rejectRequest('req-2'); + expect(result.status).toBe(ProcurementStatus.REJECTED); + }); +}); \ No newline at end of file diff --git a/backend/src/procurement/providers/procurement.service.ts b/backend/src/procurement/providers/procurement.service.ts new file mode 100644 index 00000000..c9c178cc --- /dev/null +++ b/backend/src/procurement/providers/procurement.service.ts @@ -0,0 +1,86 @@ +import { Injectable, NotFoundException, BadRequestException, Inject, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ProcurementRequest } from '../entities/procurement-request.entity'; +import { CreateProcurementRequestDto } from '../dto/create-procurement-request.dto'; +import { ProcurementStatus } from '../enums/procurement-status.enum'; +import { UsersService } from '../../users/providers/users.service'; +import { ASSET_REGISTRATION_TOKEN, AssetRegistrationService } from '../interfaces/asset-registration.interface'; + +@Injectable() +export class ProcurementService { + constructor( + @InjectRepository(ProcurementRequest) + private readonly procurementRepo: Repository, + private readonly usersService: UsersService, + @Optional() @Inject(ASSET_REGISTRATION_TOKEN) + private readonly assetRegistrationService?: AssetRegistrationService, + ) {} + + async createRequest(dto: CreateProcurementRequestDto, requestedById: string): Promise { + const user = await this.usersService.findUserById(requestedById); + if (!user) { + throw new NotFoundException('Requesting user not found'); + } + + const request = this.procurementRepo.create({ + itemName: dto.itemName, + quantity: dto.quantity, + requestedBy: user, + status: ProcurementStatus.PENDING, + }); + + return await this.procurementRepo.save(request); + } + + async approveRequest(id: string): Promise { + const request = await this.procurementRepo.findOne({ where: { id }, relations: ['requestedBy'] }); + if (!request) { + throw new NotFoundException('Procurement request not found'); + } + if (request.status !== ProcurementStatus.PENDING) { + throw new BadRequestException('Only pending requests can be approved'); + } + + request.status = ProcurementStatus.APPROVED; + const saved = await this.procurementRepo.save(request); + + // Link to asset registration if service is provided + if (this.assetRegistrationService) { + // Fire-and-forget but await to ensure consistency; wrap errors to avoid blocking core flow + try { + await this.assetRegistrationService.registerAsset({ + procurementRequestId: saved.id, + itemName: saved.itemName, + quantity: saved.quantity, + requestedById: saved.requestedBy.id, + }); + } catch (e) { + // Do not fail approval if asset registration downstream fails; in a real system consider outbox/retry + } + } + + return saved; + } + + async rejectRequest(id: string): Promise { + const request = await this.procurementRepo.findOne({ where: { id } }); + if (!request) { + throw new NotFoundException('Procurement request not found'); + } + if (request.status !== ProcurementStatus.PENDING) { + throw new BadRequestException('Only pending requests can be rejected'); + } + + request.status = ProcurementStatus.REJECTED; + return await this.procurementRepo.save(request); + } + + async findById(id: string): Promise { + const request = await this.procurementRepo.findOne({ where: { id } }); + if (!request) { + throw new NotFoundException('Procurement request not found'); + } + return request; + } +} \ No newline at end of file