Skip to content
Merged
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: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
Expand Down
10 changes: 5 additions & 5 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,10 +34,10 @@ import { AssetDepreciationModule } from './asset-depreciation/asset-depreciation
: false,
}),
}),
AuthModule,
UsersModule,
EmailModule,
SuppliersModule,
AuthModule,
UsersModule,
EmailModule,
ProcurementModule,
],
controllers: [AppController],
providers: [
Expand Down
5 changes: 3 additions & 2 deletions backend/src/config/typeorm.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';

export class CreateProcurementRequestsTable1727955600000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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);
}
}
12 changes: 12 additions & 0 deletions backend/src/procurement/dto/create-procurement-request.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions backend/src/procurement/dto/update-procurement-status.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { ProcurementStatus } from '../enums/procurement-status.enum';

export class UpdateProcurementStatusDto {
@IsEnum(ProcurementStatus)
status: ProcurementStatus;
}
36 changes: 36 additions & 0 deletions backend/src/procurement/entities/procurement-request.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions backend/src/procurement/enums/procurement-status.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum ProcurementStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
12 changes: 12 additions & 0 deletions backend/src/procurement/interfaces/asset-registration.interface.ts
Original file line number Diff line number Diff line change
@@ -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<string | void>;
}
34 changes: 34 additions & 0 deletions backend/src/procurement/procurement.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
23 changes: 23 additions & 0 deletions backend/src/procurement/procurement.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
82 changes: 82 additions & 0 deletions backend/src/procurement/providers/procurement.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ProcurementRequest>;
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>(ProcurementService);
repo = module.get(getRepositoryToken(ProcurementRequest));
usersService = module.get<UsersService>(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);
});
});
Loading