From 756114e7f0d512895ddcceff822096577ad11e89 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 27 Jun 2026 13:57:12 +0100 Subject: [PATCH 1/6] fix: resolve pre-existing lint errors across backend --- backend/package.json | 2 + .../addresses/address-validation.service.ts | 15 +++- backend/src/addresses/addresses.controller.ts | 6 ++ .../src/addresses/dto/address-response.dto.ts | 33 +++++++++ .../admin-stats.controller.spec.ts | 3 +- .../src/admin-stats/admin-stats.controller.ts | 3 +- backend/src/admin-stats/admin-stats.module.ts | 3 +- .../admin-stats/admin-stats.service.spec.ts | 3 +- .../src/admin-stats/admin-stats.service.ts | 3 +- backend/src/admin/admin.module.ts | 2 +- backend/src/admin/admin.service.spec.ts | 24 +++++-- backend/src/admin/admin.service.ts | 14 ++-- backend/src/app.module.ts | 2 +- .../audit-log/audit-log.controller.spec.ts | 10 ++- backend/src/audit-log/audit-log.service.ts | 17 ++++- .../audit-log/dto/audit-log-response.dto.ts | 41 +++++++++++ backend/src/auth/auth.module.ts | 2 +- backend/src/auth/auth.service.ts | 2 +- .../avatar-upload/avatar-upload.controller.ts | 3 +- .../src/avatar-upload/avatar-upload.module.ts | 3 +- .../avatar-upload/avatar-upload.service.ts | 16 +++-- .../src/bid-expiry/bid-expiry.service.spec.ts | 5 +- backend/src/bid-expiry/bid-expiry.service.ts | 3 +- backend/src/bids/bids.controller.ts | 6 ++ backend/src/bids/bids.service.ts | 6 +- backend/src/bids/dto/bid-response.dto.ts | 37 ++++++++++ .../bulk-shipments.controller.ts | 4 +- .../bulk-shipments/bulk-shipments.service.ts | 40 ++++++++--- .../bulk-shipments/dto/bulk-operations.dto.ts | 18 ++++- backend/src/cache/cache.module.ts | 16 ++--- backend/src/cache/carrier-cache.service.ts | 1 - backend/src/carriers/carriers.module.ts | 8 ++- .../certification-review.controller.ts | 18 ++++- .../certification-review.service.ts | 34 +++++---- backend/src/disputes/disputes.controller.ts | 24 ++++++- backend/src/disputes/disputes.service.ts | 66 ++++++++++++++---- .../src/disputes/entities/dispute.entity.ts | 11 ++- .../document-pipeline.controller.ts | 4 +- .../document-pipeline.service.spec.ts | 15 +++- .../document-pipeline.service.ts | 38 +++++++--- .../entities/document-processing.entity.ts | 13 +++- .../eta/entities/eta-calculation.entity.ts | 7 +- backend/src/eta/eta.controller.ts | 21 ++++-- backend/src/eta/eta.service.spec.ts | 20 ++++-- backend/src/eta/eta.service.ts | 42 +++++++---- .../entities/location.entity.ts | 8 ++- .../location-updates.service.ts | 46 +++++++++--- .../dto/search-marketplace.dto.ts | 59 +++++++++++++--- .../marketplace-search.controller.ts | 4 +- .../marketplace-search.service.ts | 49 ++++++++++--- .../entities/onboarding-progress.entity.ts | 9 ++- .../src/onboarding/onboarding.controller.ts | 7 +- backend/src/onboarding/onboarding.service.ts | 19 +++-- .../shipment-analytics.controller.ts | 5 +- .../shipment-analytics.service.spec.ts | 25 +++++-- .../analytics/shipment-analytics.service.ts | 17 +++-- .../document-integrity.controller.ts | 5 +- .../document-integrity.service.ts | 6 +- .../reputation-calculator.service.ts | 43 ++++++++---- .../request-logger.middleware.ts | 17 ++++- .../src/reviews/dto/review-response.dto.ts | 24 +++++++ .../route-calculator.module.ts | 2 +- .../route-calculator.service.spec.ts | 2 +- .../route-calculator.service.ts | 2 +- .../entities/escrow-transaction.entity.ts | 7 +- .../stellar-escrow/stellar-escrow.service.ts | 37 +++++++--- .../src/swagger-helpers/swagger-helpers.ts | 30 ++++++-- backend/src/users/entities/user.entity.ts | 2 +- .../src/webhooks/dto/create-webhook.dto.ts | 8 ++- .../src/webhooks/dto/webhook-response.dto.ts | 27 +++++++ backend/src/webhooks/webhooks.service.ts | 10 ++- build_errors.txt | Bin 0 -> 10644 bytes build_errors2.txt | Bin 0 -> 4084 bytes build_errors3.txt | Bin 0 -> 1692 bytes 74 files changed, 895 insertions(+), 239 deletions(-) create mode 100644 backend/src/addresses/dto/address-response.dto.ts create mode 100644 backend/src/audit-log/dto/audit-log-response.dto.ts create mode 100644 backend/src/bids/dto/bid-response.dto.ts create mode 100644 backend/src/reviews/dto/review-response.dto.ts create mode 100644 backend/src/webhooks/dto/webhook-response.dto.ts create mode 100644 build_errors.txt create mode 100644 build_errors2.txt create mode 100644 build_errors3.txt diff --git a/backend/package.json b/backend/package.json index 3e1c9b31..169dd70e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "dependencies": { "@nestjs-modules/mailer": "^2.0.2", "@nestjs/axios": "^4.0.0", + "@nestjs/bullmq": "^11.0.4", "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", @@ -48,6 +49,7 @@ "@types/qrcode": "^1.5.6", "@willsoto/nestjs-prometheus": "^6.0.2", "bcrypt": "^5.1.1", + "bullmq": "^5.79.1", "cache-manager": "^6.4.3", "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", diff --git a/backend/src/addresses/address-validation.service.ts b/backend/src/addresses/address-validation.service.ts index 805938b1..ca992b4d 100644 --- a/backend/src/addresses/address-validation.service.ts +++ b/backend/src/addresses/address-validation.service.ts @@ -10,13 +10,19 @@ const POSTAL_CODE_PATTERNS: Record = { @Injectable() export class AddressValidationService { - validate(street: string, city: string, country: string, postalCode?: string): void { + validate( + street: string, + city: string, + country: string, + postalCode?: string, + ): void { const errors: Record = {}; if (!street?.trim()) errors.street = 'Street is required'; if (!city?.trim()) errors.city = 'City is required'; if (!country?.trim() || !ISO_3166_ALPHA_2.test(country)) { - errors.country = 'Country must be a valid ISO 3166-1 alpha-2 code (e.g., NG, US, GB)'; + errors.country = + 'Country must be a valid ISO 3166-1 alpha-2 code (e.g., NG, US, GB)'; } if (postalCode && country && POSTAL_CODE_PATTERNS[country]) { @@ -26,7 +32,10 @@ export class AddressValidationService { } if (Object.keys(errors).length > 0) { - throw new BadRequestException({ message: 'Address validation failed', errors }); + throw new BadRequestException({ + message: 'Address validation failed', + errors, + }); } } } diff --git a/backend/src/addresses/addresses.controller.ts b/backend/src/addresses/addresses.controller.ts index a29c2647..ed4f2f1f 100644 --- a/backend/src/addresses/addresses.controller.ts +++ b/backend/src/addresses/addresses.controller.ts @@ -19,6 +19,7 @@ import { import { AddressesService } from './addresses.service'; import { CreateAddressDto } from './dto/create-address.dto'; import { UpdateAddressDto } from './dto/update-address.dto'; +import { AddressResponseDto } from './dto/address-response.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { User } from '../users/entities/user.entity'; @@ -37,6 +38,11 @@ export class AddressesController { @Get() @ApiOperation({ summary: 'List all saved addresses' }) + @ApiResponse({ + status: 200, + description: 'List of saved addresses', + type: [AddressResponseDto], + }) findAll(@CurrentUser() user: User) { return this.addressesService.findAll(user.id); } diff --git a/backend/src/addresses/dto/address-response.dto.ts b/backend/src/addresses/dto/address-response.dto.ts new file mode 100644 index 00000000..f1853738 --- /dev/null +++ b/backend/src/addresses/dto/address-response.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AddressResponseDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ format: 'uuid' }) + userId: string; + + @ApiProperty() + label: string; + + @ApiProperty() + address: string; + + @ApiProperty() + city: string; + + @ApiProperty() + country: string; + + @ApiPropertyOptional({ nullable: true }) + postalCode?: string | null; + + @ApiProperty() + isDefault: boolean; + + @ApiProperty() + createdAt: Date; + + @ApiPropertyOptional() + updatedAt?: Date; +} diff --git a/backend/src/admin-stats/admin-stats.controller.spec.ts b/backend/src/admin-stats/admin-stats.controller.spec.ts index d7217b60..b9596a16 100644 --- a/backend/src/admin-stats/admin-stats.controller.spec.ts +++ b/backend/src/admin-stats/admin-stats.controller.spec.ts @@ -1,4 +1,3 @@ - import { Test, TestingModule } from '@nestjs/testing'; import { AdminStatsController } from './admin-stats.controller'; import { AdminStatsService } from './admin-stats.service'; @@ -37,4 +36,4 @@ describe('AdminStatsController', () => { expect(service.getStats).toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/admin-stats/admin-stats.controller.ts b/backend/src/admin-stats/admin-stats.controller.ts index cf4fa221..99fb9fb3 100644 --- a/backend/src/admin-stats/admin-stats.controller.ts +++ b/backend/src/admin-stats/admin-stats.controller.ts @@ -1,4 +1,3 @@ - import { Controller, Get, UseGuards } from '@nestjs/common'; import { AdminStatsService } from './admin-stats.service'; import { Roles } from '../auth/decorators/roles.decorator'; @@ -15,4 +14,4 @@ export class AdminStatsController { async getStats() { return await this.adminStatsService.getStats(); } -} \ No newline at end of file +} diff --git a/backend/src/admin-stats/admin-stats.module.ts b/backend/src/admin-stats/admin-stats.module.ts index 39955e6e..1f791adc 100644 --- a/backend/src/admin-stats/admin-stats.module.ts +++ b/backend/src/admin-stats/admin-stats.module.ts @@ -1,4 +1,3 @@ - import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AdminStatsController } from './admin-stats.controller'; @@ -11,4 +10,4 @@ import { Shipment } from '../shipments/entities/shipment.entity'; controllers: [AdminStatsController], providers: [AdminStatsService], }) -export class AdminStatsModule {} \ No newline at end of file +export class AdminStatsModule {} diff --git a/backend/src/admin-stats/admin-stats.service.spec.ts b/backend/src/admin-stats/admin-stats.service.spec.ts index 02c8bb8e..c873e504 100644 --- a/backend/src/admin-stats/admin-stats.service.spec.ts +++ b/backend/src/admin-stats/admin-stats.service.spec.ts @@ -1,4 +1,3 @@ - import { Test, TestingModule } from '@nestjs/testing'; import { AdminStatsService } from './admin-stats.service'; import { getRepositoryToken } from '@nestjs/typeorm'; @@ -69,4 +68,4 @@ describe('AdminStatsService', () => { expect(shipmentRepository.createQueryBuilder).toHaveBeenCalledTimes(4); }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/admin-stats/admin-stats.service.ts b/backend/src/admin-stats/admin-stats.service.ts index 2ceed1e7..00bf5844 100644 --- a/backend/src/admin-stats/admin-stats.service.ts +++ b/backend/src/admin-stats/admin-stats.service.ts @@ -1,4 +1,3 @@ - import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -75,4 +74,4 @@ export class AdminStatsService { topCarriers, }; } -} \ No newline at end of file +} diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index a91066c2..fa6f3dc1 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -16,4 +16,4 @@ import { AdminStatsModule } from '../admin-stats/admin-stats.module'; controllers: [AdminController], providers: [AdminService], }) -export class AdminModule {} \ No newline at end of file +export class AdminModule {} diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index e7b90e00..53c1fbc2 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -1,4 +1,3 @@ - import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AdminService } from './admin.service'; @@ -69,12 +68,20 @@ describe('AdminService', () => { describe('listUsers', () => { it('should return cached data if available for carriers', async () => { const query = { role: UserRole.CARRIER, page: 1, limit: 10 }; - const cachedData = { data: [], total: 0, page: 1, limit: 10, totalPages: 1 }; + const cachedData = { + data: [], + total: 0, + page: 1, + limit: 10, + totalPages: 1, + }; mockCacheManager.get.mockResolvedValue(cachedData); const result = await service.listUsers(query); - expect(mockCacheManager.get).toHaveBeenCalledWith(`carriers:${JSON.stringify(query)}`); + expect(mockCacheManager.get).toHaveBeenCalledWith( + `carriers:${JSON.stringify(query)}`, + ); expect(response.header).toHaveBeenCalledWith('X-Cache', 'HIT'); expect(result).toEqual(cachedData); expect(userRepository.findAndCount).not.toHaveBeenCalled(); @@ -88,9 +95,14 @@ describe('AdminService', () => { const result = await service.listUsers(query); - expect(mockCacheManager.get).toHaveBeenCalledWith(`carriers:${JSON.stringify(query)}`); + expect(mockCacheManager.get).toHaveBeenCalledWith( + `carriers:${JSON.stringify(query)}`, + ); expect(userRepository.findAndCount).toHaveBeenCalled(); - expect(mockCacheManager.set).toHaveBeenCalledWith(`carriers:${JSON.stringify(query)}`, dbData); + expect(mockCacheManager.set).toHaveBeenCalledWith( + `carriers:${JSON.stringify(query)}`, + dbData, + ); expect(response.header).toHaveBeenCalledWith('X-Cache', 'MISS'); expect(result).toEqual(dbData); }); @@ -114,4 +126,4 @@ describe('AdminService', () => { expect(mockCacheManager.del).toHaveBeenCalledWith('carriers:{}'); }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 3041ca98..0b56ef89 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -1,4 +1,3 @@ - import { Injectable, NotFoundException, @@ -66,7 +65,8 @@ export class AdminService { async listUsers(query: QueryUsersDto): Promise { if (query.role === UserRole.CARRIER) { - const cachedData = await this.carrierCacheService.getCarriers(query); + const cachedData = + await this.carrierCacheService.getCarriers(query); if (cachedData) { this.request.res.header('X-Cache', 'HIT'); return cachedData; @@ -87,7 +87,13 @@ export class AdminService { take: limit, }); - const result = { data, total, page, limit, totalPages: Math.ceil(total / limit) }; + const result = { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; if (query.role === UserRole.CARRIER) { await this.carrierCacheService.setCarriers(query, result as any); @@ -247,4 +253,4 @@ export class AdminService { }, }; } -} \ No newline at end of file +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8c5a08ef..2947087e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -144,4 +144,4 @@ const throttlerErrorMessage = (context: ExecutionContext): string => { }, ], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/backend/src/audit-log/audit-log.controller.spec.ts b/backend/src/audit-log/audit-log.controller.spec.ts index 173307a8..93252946 100644 --- a/backend/src/audit-log/audit-log.controller.spec.ts +++ b/backend/src/audit-log/audit-log.controller.spec.ts @@ -25,11 +25,15 @@ describe('AuditLogController', () => { it('should not have a DELETE endpoint', () => { const prototype = Object.getPrototypeOf(controller); - const methodNames = Object.getOwnPropertyNames(prototype) - .filter((name) => name !== 'constructor' && typeof prototype[name] === 'function'); + const methodNames = Object.getOwnPropertyNames(prototype).filter( + (name) => name !== 'constructor' && typeof prototype[name] === 'function', + ); for (const methodName of methodNames) { - const httpMethod = Reflect.getMetadata(METHODS_METADATA, prototype[methodName]); + const httpMethod = Reflect.getMetadata( + METHODS_METADATA, + prototype[methodName], + ); expect(httpMethod).not.toBe(RequestMethod.DELETE); } }); diff --git a/backend/src/audit-log/audit-log.service.ts b/backend/src/audit-log/audit-log.service.ts index 4c569b3f..5481e9c7 100644 --- a/backend/src/audit-log/audit-log.service.ts +++ b/backend/src/audit-log/audit-log.service.ts @@ -31,7 +31,14 @@ export class AuditLogService { } async findAll(query: QueryAuditLogDto) { - const { page = 1, limit = 20, action, targetType, dateFrom, dateTo } = query; + const { + page = 1, + limit = 20, + action, + targetType, + dateFrom, + dateTo, + } = query; const skip = (page - 1) * limit; const qb = this.auditLogRepo @@ -43,8 +50,12 @@ export class AuditLogService { if (action) qb.andWhere('log.action = :action', { action }); if (targetType) qb.andWhere('log.targetType = :targetType', { targetType }); - if (dateFrom) qb.andWhere('log.createdAt >= :dateFrom', { dateFrom: new Date(dateFrom) }); - if (dateTo) qb.andWhere('log.createdAt <= :dateTo', { dateTo: new Date(dateTo) }); + if (dateFrom) + qb.andWhere('log.createdAt >= :dateFrom', { + dateFrom: new Date(dateFrom), + }); + if (dateTo) + qb.andWhere('log.createdAt <= :dateTo', { dateTo: new Date(dateTo) }); const [data, total] = await qb.getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; diff --git a/backend/src/audit-log/dto/audit-log-response.dto.ts b/backend/src/audit-log/dto/audit-log-response.dto.ts new file mode 100644 index 00000000..11640cac --- /dev/null +++ b/backend/src/audit-log/dto/audit-log-response.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AuditLogResponseDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ format: 'uuid' }) + adminId: string; + + @ApiProperty({ example: 'USER_DEACTIVATED' }) + action: string; + + @ApiPropertyOptional({ type: String, nullable: true }) + targetType: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + targetId: string | null; + + @ApiPropertyOptional({ type: Object, nullable: true }) + metadata: Record | null; + + @ApiProperty() + createdAt: Date; +} + +export class PaginatedAuditLogResponseDto { + @ApiProperty({ type: [AuditLogResponseDto] }) + data: AuditLogResponseDto[]; + + @ApiProperty() + total: number; + + @ApiProperty() + page: number; + + @ApiProperty() + limit: number; + + @ApiProperty() + totalPages: number; +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index f5275670..a4ac667f 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -43,4 +43,4 @@ import { AvatarUploadModule } from '../avatar-upload/avatar-upload.module'; ], exports: [AuthService], }) -export class AuthModule {} \ No newline at end of file +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index d53e0e2e..c27a9de3 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -286,4 +286,4 @@ export class AuthService { refreshToken: tokens.refreshToken, }; } -} \ No newline at end of file +} diff --git a/backend/src/avatar-upload/avatar-upload.controller.ts b/backend/src/avatar-upload/avatar-upload.controller.ts index c5dcfe45..64edc858 100644 --- a/backend/src/avatar-upload/avatar-upload.controller.ts +++ b/backend/src/avatar-upload/avatar-upload.controller.ts @@ -1,4 +1,3 @@ - import { Controller, Post, @@ -22,4 +21,4 @@ export class AvatarUploadController { ) { return await this.avatarUploadService.uploadAvatar(user, file); } -} \ No newline at end of file +} diff --git a/backend/src/avatar-upload/avatar-upload.module.ts b/backend/src/avatar-upload/avatar-upload.module.ts index 2b4eb64e..b452737d 100644 --- a/backend/src/avatar-upload/avatar-upload.module.ts +++ b/backend/src/avatar-upload/avatar-upload.module.ts @@ -1,4 +1,3 @@ - import { Module } from '@nestjs/common'; import { AvatarUploadController } from './avatar-upload.controller'; import { AvatarUploadService } from './avatar-upload.service'; @@ -7,4 +6,4 @@ import { AvatarUploadService } from './avatar-upload.service'; controllers: [AvatarUploadController], providers: [AvatarUploadService], }) -export class AvatarUploadModule {} \ No newline at end of file +export class AvatarUploadModule {} diff --git a/backend/src/avatar-upload/avatar-upload.service.ts b/backend/src/avatar-upload/avatar-upload.service.ts index e43ee6df..46120ba1 100644 --- a/backend/src/avatar-upload/avatar-upload.service.ts +++ b/backend/src/avatar-upload/avatar-upload.service.ts @@ -1,4 +1,3 @@ - import { Injectable } from '@nestjs/common'; import { Express } from 'express'; import { v4 as uuidv4 } from 'uuid'; @@ -21,7 +20,9 @@ export class AvatarUploadService { const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!allowedMimeTypes.includes(file.mimetype)) { - throw new Error('Invalid file type. Only JPEG, PNG, and WEBP are allowed.'); + throw new Error( + 'Invalid file type. Only JPEG, PNG, and WEBP are allowed.', + ); } const maxFileSize = 2 * 1024 * 1024; // 2MB @@ -43,7 +44,14 @@ export class AvatarUploadService { } const fileName = `${uuidv4()}${path.extname(file.originalname)}`; - const filePath = path.join(__dirname, '..', '..', 'uploads', 'avatars', fileName); + const filePath = path.join( + __dirname, + '..', + '..', + 'uploads', + 'avatars', + fileName, + ); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { @@ -58,4 +66,4 @@ export class AvatarUploadService { return { avatarUrl }; } -} \ No newline at end of file +} diff --git a/backend/src/bid-expiry/bid-expiry.service.spec.ts b/backend/src/bid-expiry/bid-expiry.service.spec.ts index c57ab852..76ba9345 100644 --- a/backend/src/bid-expiry/bid-expiry.service.spec.ts +++ b/backend/src/bid-expiry/bid-expiry.service.spec.ts @@ -16,7 +16,10 @@ describe('BidExpiryService', () => { providers: [ BidExpiryService, { provide: getRepositoryToken(Bid), useValue: mockBidRepo }, - { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue(72) } }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue(72) }, + }, ], }).compile(); service = module.get(BidExpiryService); diff --git a/backend/src/bid-expiry/bid-expiry.service.ts b/backend/src/bid-expiry/bid-expiry.service.ts index 76f19f02..31cb4b36 100644 --- a/backend/src/bid-expiry/bid-expiry.service.ts +++ b/backend/src/bid-expiry/bid-expiry.service.ts @@ -16,7 +16,8 @@ export class BidExpiryService { @Cron(CronExpression.EVERY_HOUR) async expireStaleBids(): Promise { - const expiryHours = this.configService.get('BID_EXPIRY_HOURS') ?? 72; + const expiryHours = + this.configService.get('BID_EXPIRY_HOURS') ?? 72; const threshold = new Date(Date.now() - expiryHours * 3600_000); const result = await this.bidRepo.update( diff --git a/backend/src/bids/bids.controller.ts b/backend/src/bids/bids.controller.ts index 69b3101c..a2dcef85 100644 --- a/backend/src/bids/bids.controller.ts +++ b/backend/src/bids/bids.controller.ts @@ -20,6 +20,7 @@ import { import { BidsService } from './bids.service'; import { CreateBidDto } from './dto/create-bid.dto'; import { CounterBidDto } from './dto/counter-bid.dto'; +import { BidResponseDto } from './dto/bid-response.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -51,6 +52,11 @@ export class BidsController { @Roles(UserRole.SHIPPER, UserRole.ADMIN) @ApiOperation({ summary: 'Shipper views all bids on their shipment' }) @ApiParam({ name: 'id', description: 'Shipment ID' }) + @ApiResponse({ + status: 200, + description: 'List of bids for the shipment', + type: [BidResponseDto], + }) getBids( @Param('id', ParseUUIDPipe) shipmentId: string, @CurrentUser() user: User, diff --git a/backend/src/bids/bids.service.ts b/backend/src/bids/bids.service.ts index d394fdd6..3dd8ff50 100644 --- a/backend/src/bids/bids.service.ts +++ b/backend/src/bids/bids.service.ts @@ -142,7 +142,7 @@ export class BidsService { } if (shipment.status !== ShipmentStatus.PENDING) { throw new BadRequestException('Shipment is no longer accepting bids'); - } + } const bid = await this.bidRepo.findOne({ where: { id: bidId, shipmentId }, @@ -150,7 +150,9 @@ export class BidsService { if (!bid) throw new NotFoundException(`Bid ${bidId} not found`); if (this.isBidExpired(bid)) { - throw new BadRequestException('This bid has expired and can no longer be accepted'); + throw new BadRequestException( + 'This bid has expired and can no longer be accepted', + ); } bid.status = BidStatus.ACCEPTED; diff --git a/backend/src/bids/dto/bid-response.dto.ts b/backend/src/bids/dto/bid-response.dto.ts new file mode 100644 index 00000000..334f3ae8 --- /dev/null +++ b/backend/src/bids/dto/bid-response.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { BidStatus } from '../entities/bid.entity'; + +export class BidResponseDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ format: 'uuid' }) + shipmentId: string; + + @ApiProperty({ format: 'uuid' }) + carrierId: string; + + @ApiProperty({ type: Number }) + proposedPrice: number; + + @ApiPropertyOptional({ type: String, nullable: true }) + message: string | null; + + @ApiProperty({ enum: Object.values(BidStatus), enumName: 'BidStatus' }) + status: string; + + @ApiPropertyOptional({ type: Number, nullable: true }) + counterPrice: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + counterMessage: string | null; + + @ApiPropertyOptional({ type: Date, nullable: true }) + expiresAt: Date | null; + + @ApiPropertyOptional({ type: Date, nullable: true }) + counterOfferedAt: Date | null; + + @ApiProperty() + createdAt: Date; +} diff --git a/backend/src/bulk-shipments/bulk-shipments.controller.ts b/backend/src/bulk-shipments/bulk-shipments.controller.ts index 52291936..b65ea0cf 100644 --- a/backend/src/bulk-shipments/bulk-shipments.controller.ts +++ b/backend/src/bulk-shipments/bulk-shipments.controller.ts @@ -23,7 +23,9 @@ export class BulkShipmentsController { } @Post('update-status') - @ApiOperation({ summary: 'Update status of multiple shipments at once (max 50)' }) + @ApiOperation({ + summary: 'Update status of multiple shipments at once (max 50)', + }) updateStatus(@CurrentUser() user: User, @Body() dto: BulkUpdateStatusDto) { return this.bulkShipmentsService.updateStatus(user.id, dto.ids, dto.status); } diff --git a/backend/src/bulk-shipments/bulk-shipments.service.ts b/backend/src/bulk-shipments/bulk-shipments.service.ts index 39978df6..bb3fe2a6 100644 --- a/backend/src/bulk-shipments/bulk-shipments.service.ts +++ b/backend/src/bulk-shipments/bulk-shipments.service.ts @@ -1,4 +1,4 @@ -import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { Shipment } from '../shipments/entities/shipment.entity'; @@ -7,20 +7,34 @@ import { ShipmentStatus } from '../common/enums/shipment-status.enum'; @Injectable() export class BulkShipmentsService { constructor( - @InjectRepository(Shipment) private readonly shipmentRepo: Repository, + @InjectRepository(Shipment) + private readonly shipmentRepo: Repository, ) {} - async cancel(userId: string, ids: string[]): Promise<{ succeeded: string[]; failed: { id: string; reason: string }[] }> { - return this.bulkOperation(userId, ids, async (shipment) => { + async cancel( + userId: string, + ids: string[], + ): Promise<{ + succeeded: string[]; + failed: { id: string; reason: string }[]; + }> { + return this.bulkOperation(userId, ids, (shipment) => { shipment.status = ShipmentStatus.CANCELLED; - return shipment; + return Promise.resolve(shipment); }); } - async updateStatus(userId: string, ids: string[], status: ShipmentStatus): Promise<{ succeeded: string[]; failed: { id: string; reason: string }[] }> { - return this.bulkOperation(userId, ids, async (shipment) => { + async updateStatus( + userId: string, + ids: string[], + status: ShipmentStatus, + ): Promise<{ + succeeded: string[]; + failed: { id: string; reason: string }[]; + }> { + return this.bulkOperation(userId, ids, (shipment) => { shipment.status = status; - return shipment; + return Promise.resolve(shipment); }); } @@ -28,15 +42,19 @@ export class BulkShipmentsService { userId: string, ids: string[], operation: (shipment: Shipment) => Promise, - ): Promise<{ succeeded: string[]; failed: { id: string; reason: string }[] }> { - if (ids.length > 50) throw new BadRequestException('Maximum 50 IDs per request'); + ): Promise<{ + succeeded: string[]; + failed: { id: string; reason: string }[]; + }> { + if (ids.length > 50) + throw new BadRequestException('Maximum 50 IDs per request'); const shipments = await this.shipmentRepo.find({ where: { id: In(ids) } }); const succeeded: string[] = []; const failed: { id: string; reason: string }[] = []; for (const id of ids) { - const shipment = shipments.find(s => s.id === id); + const shipment = shipments.find((s) => s.id === id); if (!shipment) { failed.push({ id, reason: 'Shipment not found' }); continue; diff --git a/backend/src/bulk-shipments/dto/bulk-operations.dto.ts b/backend/src/bulk-shipments/dto/bulk-operations.dto.ts index 702363ee..e3fe8b1e 100644 --- a/backend/src/bulk-shipments/dto/bulk-operations.dto.ts +++ b/backend/src/bulk-shipments/dto/bulk-operations.dto.ts @@ -1,9 +1,18 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsString, IsUUID, ArrayMaxSize, ArrayMinSize, IsEnum } from 'class-validator'; +import { + IsArray, + IsUUID, + ArrayMaxSize, + ArrayMinSize, + IsEnum, +} from 'class-validator'; import { ShipmentStatus } from '../../common/enums/shipment-status.enum'; export class BulkCancelDto { - @ApiProperty({ description: 'Array of shipment IDs to cancel', maxLength: 50 }) + @ApiProperty({ + description: 'Array of shipment IDs to cancel', + maxLength: 50, + }) @IsArray() @IsUUID('4', { each: true }) @ArrayMinSize(1) @@ -12,7 +21,10 @@ export class BulkCancelDto { } export class BulkUpdateStatusDto { - @ApiProperty({ description: 'Array of shipment IDs to update', maxLength: 50 }) + @ApiProperty({ + description: 'Array of shipment IDs to update', + maxLength: 50, + }) @IsArray() @IsUUID('4', { each: true }) @ArrayMinSize(1) diff --git a/backend/src/cache/cache.module.ts b/backend/src/cache/cache.module.ts index 745c8ef4..c6316009 100644 --- a/backend/src/cache/cache.module.ts +++ b/backend/src/cache/cache.module.ts @@ -1,4 +1,3 @@ - import { Module } from '@nestjs/common'; import { CacheModule as NestCacheModule } from '@nestjs/cache-manager'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -8,15 +7,16 @@ import { redisStore } from 'cache-manager-redis-yet'; imports: [ NestCacheModule.registerAsync({ imports: [ConfigModule], - useFactory: async (configService: ConfigService) => ({ - store: await redisStore({ - url: configService.get('REDIS_URL'), - ttl: 5 * 60 * 1000, // 5 minutes - }), - }), + useFactory: async (configService: ConfigService) => { + const store = await redisStore({ + url: configService.get('REDIS_URL'), + ttl: 5 * 60 * 1000, + }); + return { store }; + }, inject: [ConfigService], }), ], exports: [NestCacheModule], }) -export class CacheModule {} \ No newline at end of file +export class CacheModule {} diff --git a/backend/src/cache/carrier-cache.service.ts b/backend/src/cache/carrier-cache.service.ts index fcdfc4f3..5146f741 100644 --- a/backend/src/cache/carrier-cache.service.ts +++ b/backend/src/cache/carrier-cache.service.ts @@ -1,4 +1,3 @@ - import { Injectable, Inject } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; diff --git a/backend/src/carriers/carriers.module.ts b/backend/src/carriers/carriers.module.ts index 39c58661..7ac92de9 100644 --- a/backend/src/carriers/carriers.module.ts +++ b/backend/src/carriers/carriers.module.ts @@ -14,7 +14,11 @@ import { CarrierCacheService } from '../cache/carrier-cache.service'; CacheModule, ], controllers: [CarriersController], - providers: [CarriersService, CarrierCertificationsService, CarrierCacheService], + providers: [ + CarriersService, + CarrierCertificationsService, + CarrierCacheService, + ], exports: [CarrierCertificationsService, CarrierCacheService], }) -export class CarriersModule {} \ No newline at end of file +export class CarriersModule {} diff --git a/backend/src/certification-review/certification-review.controller.ts b/backend/src/certification-review/certification-review.controller.ts index 0c16152f..aba68754 100644 --- a/backend/src/certification-review/certification-review.controller.ts +++ b/backend/src/certification-review/certification-review.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Patch, Param, Body, Query, ParseUUIDPipe, UseGuards, BadRequestException } from '@nestjs/common'; +import { + Controller, + Get, + Patch, + Param, + Body, + Query, + ParseUUIDPipe, + UseGuards, + BadRequestException, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CertificationReviewService } from './certification-review.service'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -29,7 +39,11 @@ export class CertificationReviewController { @Patch(':id/reject') @ApiOperation({ summary: 'Reject a carrier certification with reason' }) - reject(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, @Body('reason') reason: string) { + reject( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + @Body('reason') reason: string, + ) { if (!reason) throw new BadRequestException('Rejection reason is required'); return this.service.reject(id, user.id, reason); } diff --git a/backend/src/certification-review/certification-review.service.ts b/backend/src/certification-review/certification-review.service.ts index a3b27d2e..45738e75 100644 --- a/backend/src/certification-review/certification-review.service.ts +++ b/backend/src/certification-review/certification-review.service.ts @@ -3,31 +3,35 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { MailerService } from '@nestjs-modules/mailer'; -interface Certification { - id: string; - carrierId: string; - isVerified: boolean; - status: string; - fileName?: string; -} - @Injectable() export class CertificationReviewService { constructor( - @InjectRepository(null as never) private readonly dummyRepo: Repository, + @InjectRepository(null as never) + private readonly dummyRepo: Repository, private readonly mailerService: MailerService, ) {} - async findPending(page = 1, limit = 20) { - return { data: [], total: 0, page, limit, totalPages: 0 }; + findPending(page = 1, limit = 20) { + return Promise.resolve({ data: [], total: 0, page, limit, totalPages: 0 }); } - async approve(certificationId: string, adminId: string): Promise<{ message: string }> { - return { message: `Certification ${certificationId} approved by admin ${adminId}` }; + approve( + certificationId: string, + adminId: string, + ): Promise<{ message: string }> { + return Promise.resolve({ + message: `Certification ${certificationId} approved by admin ${adminId}`, + }); } - async reject(certificationId: string, adminId: string, reason: string): Promise<{ message: string }> { + reject( + certificationId: string, + adminId: string, + reason: string, + ): Promise<{ message: string }> { if (!reason) throw new NotFoundException('Rejection reason is required'); - return { message: `Certification ${certificationId} rejected by admin ${adminId}: ${reason}` }; + return Promise.resolve({ + message: `Certification ${certificationId} rejected by admin ${adminId}: ${reason}`, + }); } } diff --git a/backend/src/disputes/disputes.controller.ts b/backend/src/disputes/disputes.controller.ts index a1f41e70..413d5415 100644 --- a/backend/src/disputes/disputes.controller.ts +++ b/backend/src/disputes/disputes.controller.ts @@ -1,4 +1,14 @@ -import { Controller, Get, Post, Patch, Param, Body, Query, ParseUUIDPipe, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { DisputesService } from './disputes.service'; import { CreateDisputeDto } from './dto/create-dispute.dto'; @@ -32,7 +42,11 @@ export class DisputesController { @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Resolve a dispute (admin only)' }) - resolve(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: User, @Body() dto: ResolveDisputeDto) { + resolve( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: User, + @Body() dto: ResolveDisputeDto, + ) { return this.disputesService.resolve(id, user.id, dto); } @@ -40,7 +54,11 @@ export class DisputesController { @UseGuards(RolesGuard) @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'List disputes for admin (filterable by status)' }) - findAllAdmin(@Query('page') page?: number, @Query('limit') limit?: number, @Query('status') status?: DisputeStatus) { + findAllAdmin( + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('status') status?: DisputeStatus, + ) { return this.disputesService.findAllAdmin({ page, limit, status }); } } diff --git a/backend/src/disputes/disputes.service.ts b/backend/src/disputes/disputes.service.ts index d049d69a..f529620d 100644 --- a/backend/src/disputes/disputes.service.ts +++ b/backend/src/disputes/disputes.service.ts @@ -1,4 +1,9 @@ -import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Dispute, DisputeStatus } from './entities/dispute.entity'; @@ -10,34 +15,61 @@ import { UserRole } from '../common/enums/role.enum'; @Injectable() export class DisputesService { constructor( - @InjectRepository(Dispute) private readonly disputeRepo: Repository, - @InjectRepository(Shipment) private readonly shipmentRepo: Repository, + @InjectRepository(Dispute) + private readonly disputeRepo: Repository, + @InjectRepository(Shipment) + private readonly shipmentRepo: Repository, ) {} async create(userId: string, dto: CreateDisputeDto): Promise { - const shipment = await this.shipmentRepo.findOne({ where: { id: dto.shipmentId } }); + const shipment = await this.shipmentRepo.findOne({ + where: { id: dto.shipmentId }, + }); if (!shipment) throw new NotFoundException('Shipment not found'); if (shipment.shipperId !== userId && shipment.carrierId !== userId) { - throw new ForbiddenException('You must be a party to the shipment to open a dispute'); + throw new ForbiddenException( + 'You must be a party to the shipment to open a dispute', + ); } - const active = await this.disputeRepo.findOne({ where: { shipmentId: dto.shipmentId, status: DisputeStatus.OPEN } }); - if (active) throw new BadRequestException('An active dispute already exists for this shipment'); + const active = await this.disputeRepo.findOne({ + where: { shipmentId: dto.shipmentId, status: DisputeStatus.OPEN }, + }); + if (active) + throw new BadRequestException( + 'An active dispute already exists for this shipment', + ); - const dispute = this.disputeRepo.create({ shipmentId: dto.shipmentId, openedById: userId, reason: dto.reason }); + const dispute = this.disputeRepo.create({ + shipmentId: dto.shipmentId, + openedById: userId, + reason: dto.reason, + }); return this.disputeRepo.save(dispute); } async findOne(id: string, userId: string, role: UserRole): Promise { - const dispute = await this.disputeRepo.findOne({ where: { id }, relations: ['shipment'] }); + const dispute = await this.disputeRepo.findOne({ + where: { id }, + relations: ['shipment'], + }); if (!dispute) throw new NotFoundException('Dispute not found'); - if (role !== UserRole.ADMIN && dispute.openedById !== userId && dispute.shipment.shipperId !== userId && dispute.shipment.carrierId !== userId) { + if ( + role !== UserRole.ADMIN && + dispute.openedById !== userId && + dispute.shipment.shipperId !== userId && + dispute.shipment.carrierId !== userId + ) { throw new ForbiddenException(); } return dispute; } - async resolve(id: string, adminId: string, dto: ResolveDisputeDto): Promise { + async resolve( + id: string, + adminId: string, + dto: ResolveDisputeDto, + ): Promise { const dispute = await this.disputeRepo.findOne({ where: { id } }); if (!dispute) throw new NotFoundException('Dispute not found'); dispute.status = DisputeStatus.RESOLVED; @@ -46,12 +78,18 @@ export class DisputesService { return this.disputeRepo.save(dispute); } - async findAllAdmin(query: { page?: number; limit?: number; status?: DisputeStatus }) { + async findAllAdmin(query: { + page?: number; + limit?: number; + status?: DisputeStatus; + }) { const { page = 1, limit = 20, status } = query; - const qb = this.disputeRepo.createQueryBuilder('d') + const qb = this.disputeRepo + .createQueryBuilder('d') .leftJoinAndSelect('d.shipment', 'shipment') .orderBy('d.createdAt', 'DESC') - .skip((page - 1) * limit).take(limit); + .skip((page - 1) * limit) + .take(limit); if (status) qb.andWhere('d.status = :status', { status }); const [data, total] = await qb.getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; diff --git a/backend/src/disputes/entities/dispute.entity.ts b/backend/src/disputes/entities/dispute.entity.ts index 2620501b..771976b1 100644 --- a/backend/src/disputes/entities/dispute.entity.ts +++ b/backend/src/disputes/entities/dispute.entity.ts @@ -1,4 +1,13 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; import { User } from '../../users/entities/user.entity'; import { Shipment } from '../../shipments/entities/shipment.entity'; diff --git a/backend/src/document-pipeline/document-pipeline.controller.ts b/backend/src/document-pipeline/document-pipeline.controller.ts index 637ecf48..21141f08 100644 --- a/backend/src/document-pipeline/document-pipeline.controller.ts +++ b/backend/src/document-pipeline/document-pipeline.controller.ts @@ -9,7 +9,9 @@ export class DocumentPipelineController { constructor(private readonly service: DocumentPipelineService) {} @Post(':id/process') - @ApiOperation({ summary: 'Enqueue document for async processing, returns 202' }) + @ApiOperation({ + summary: 'Enqueue document for async processing, returns 202', + }) async enqueue(@Param('id', ParseUUIDPipe) id: string) { await this.service.enqueue(id); return this.service.process(id); diff --git a/backend/src/document-pipeline/document-pipeline.service.spec.ts b/backend/src/document-pipeline/document-pipeline.service.spec.ts index a43dbb26..8a46d8a5 100644 --- a/backend/src/document-pipeline/document-pipeline.service.spec.ts +++ b/backend/src/document-pipeline/document-pipeline.service.spec.ts @@ -1,7 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DocumentPipelineService } from './document-pipeline.service'; -import { DocumentProcessing, ProcessingStatus } from './entities/document-processing.entity'; +import { + DocumentProcessing, + ProcessingStatus, +} from './entities/document-processing.entity'; import { Document } from '../documents/entities/document.entity'; describe('DocumentPipelineService', () => { @@ -21,7 +24,10 @@ describe('DocumentPipelineService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ DocumentPipelineService, - { provide: getRepositoryToken(DocumentProcessing), useValue: mockProcRepo }, + { + provide: getRepositoryToken(DocumentProcessing), + useValue: mockProcRepo, + }, { provide: getRepositoryToken(Document), useValue: mockDocRepo }, ], }).compile(); @@ -29,7 +35,10 @@ describe('DocumentPipelineService', () => { }); it('processes a document successfully', async () => { - mockDocRepo.findOne.mockResolvedValue({ id: 'doc-1', fileName: 'test.pdf' }); + mockDocRepo.findOne.mockResolvedValue({ + id: 'doc-1', + fileName: 'test.pdf', + }); mockProcRepo.findOne.mockResolvedValue(null); mockProcRepo.create.mockReturnValue({}); mockProcRepo.save.mockResolvedValue({ status: ProcessingStatus.READY }); diff --git a/backend/src/document-pipeline/document-pipeline.service.ts b/backend/src/document-pipeline/document-pipeline.service.ts index 47ce7d30..666e667f 100644 --- a/backend/src/document-pipeline/document-pipeline.service.ts +++ b/backend/src/document-pipeline/document-pipeline.service.ts @@ -2,7 +2,10 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { createHash } from 'node:crypto'; -import { DocumentProcessing, ProcessingStatus } from './entities/document-processing.entity'; +import { + DocumentProcessing, + ProcessingStatus, +} from './entities/document-processing.entity'; import { Document } from '../documents/entities/document.entity'; const VALID_MIME_TYPES = [ @@ -16,7 +19,8 @@ const VALID_MIME_TYPES = [ @Injectable() export class DocumentPipelineService { constructor( - @InjectRepository(DocumentProcessing) private readonly procRepo: Repository, + @InjectRepository(DocumentProcessing) + private readonly procRepo: Repository, @InjectRepository(Document) private readonly docRepo: Repository, ) {} @@ -24,14 +28,23 @@ export class DocumentPipelineService { const doc = await this.docRepo.findOne({ where: { id: documentId } }); if (!doc) throw new BadRequestException('Document not found'); - const processing = this.procRepo.create({ documentId, status: ProcessingStatus.PENDING }); + const processing = this.procRepo.create({ + documentId, + status: ProcessingStatus.PENDING, + }); return this.procRepo.save(processing); } async process(documentId: string): Promise { - let proc = await this.procRepo.findOne({ where: { documentId }, order: { createdAt: 'DESC' } }); + let proc = await this.procRepo.findOne({ + where: { documentId }, + order: { createdAt: 'DESC' }, + }); if (!proc) { - proc = this.procRepo.create({ documentId, status: ProcessingStatus.PENDING }); + proc = this.procRepo.create({ + documentId, + status: ProcessingStatus.PENDING, + }); } proc.status = ProcessingStatus.PROCESSING; @@ -53,22 +66,29 @@ export class DocumentPipelineService { proc.status = ProcessingStatus.READY; } catch (error) { proc.status = ProcessingStatus.FAILED; - proc.errorReason = error instanceof Error ? error.message : 'Unknown error'; + proc.errorReason = + error instanceof Error ? error.message : 'Unknown error'; } return this.procRepo.save(proc); } async getStatus(documentId: string): Promise { - return this.procRepo.findOne({ where: { documentId }, order: { createdAt: 'DESC' } }); + return this.procRepo.findOne({ + where: { documentId }, + order: { createdAt: 'DESC' }, + }); } private guessMimeType(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase(); const map: Record = { pdf: 'application/pdf', - jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', - doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', }; return map[ext ?? ''] ?? 'application/octet-stream'; } diff --git a/backend/src/document-pipeline/entities/document-processing.entity.ts b/backend/src/document-pipeline/entities/document-processing.entity.ts index cb1f8012..edbd9e34 100644 --- a/backend/src/document-pipeline/entities/document-processing.entity.ts +++ b/backend/src/document-pipeline/entities/document-processing.entity.ts @@ -1,4 +1,9 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; export enum ProcessingStatus { PENDING = 'PENDING', @@ -15,7 +20,11 @@ export class DocumentProcessing { @Column({ name: 'document_id', type: 'uuid' }) documentId: string; - @Column({ type: 'enum', enum: ProcessingStatus, default: ProcessingStatus.PENDING }) + @Column({ + type: 'enum', + enum: ProcessingStatus, + default: ProcessingStatus.PENDING, + }) status: ProcessingStatus; @Column({ name: 'mime_type', nullable: true, length: 100 }) diff --git a/backend/src/eta/entities/eta-calculation.entity.ts b/backend/src/eta/entities/eta-calculation.entity.ts index 00ac9abf..55d4a776 100644 --- a/backend/src/eta/entities/eta-calculation.entity.ts +++ b/backend/src/eta/entities/eta-calculation.entity.ts @@ -1,4 +1,9 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; @Entity('eta_calculations') export class ETACalculation { diff --git a/backend/src/eta/eta.controller.ts b/backend/src/eta/eta.controller.ts index 3439a947..d0d49aa0 100644 --- a/backend/src/eta/eta.controller.ts +++ b/backend/src/eta/eta.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Post, Get, Param, Body, ParseUUIDPipe } from '@nestjs/common'; +import { + Controller, + Post, + Get, + Param, + Body, + ParseUUIDPipe, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ETAService } from './eta.service'; import { CalculateETADto } from './dto/calculate-eta.dto'; @@ -11,13 +18,19 @@ export class ETAController { @Post(':id/calculate-eta') @ApiOperation({ summary: 'Calculate ETA for a shipment' }) - calculateETA(@Param('id', ParseUUIDPipe) id: string, @Body() dto: CalculateETADto) { + calculateETA( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CalculateETADto, + ) { return this.etaService.recalculateForShipment(id, dto); } @Get(':id/eta') @ApiOperation({ summary: 'Get estimated delivery info for a shipment' }) - getETA(@Param('id', ParseUUIDPipe) id: string) { - return this.etaService.calculateETA({ originCity: 'Lagos', destinationCity: 'Abuja' }); + getETA(@Param('id', ParseUUIDPipe) _id: string) { + return this.etaService.calculateETA({ + originCity: 'Lagos', + destinationCity: 'Abuja', + }); } } diff --git a/backend/src/eta/eta.service.spec.ts b/backend/src/eta/eta.service.spec.ts index 6106436c..2b2c71e6 100644 --- a/backend/src/eta/eta.service.spec.ts +++ b/backend/src/eta/eta.service.spec.ts @@ -18,25 +18,37 @@ describe('ETAService', () => { providers: [ ETAService, { provide: getRepositoryToken(ETACalculation), useValue: mockRepo }, - { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue(60) } }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue(60) }, + }, ], }).compile(); service = module.get(ETAService); }); it('calculates ETA for on-time carrier', async () => { - const result = await service.calculateETA({ originCity: 'Lagos', destinationCity: 'Abuja' }); + const result = await service.calculateETA({ + originCity: 'Lagos', + destinationCity: 'Abuja', + }); expect(result.estimatedHours).toBeGreaterThan(0); expect(result.confidenceLevel).toBeDefined(); }); it('applies buffer for unreliable carrier', async () => { - const result = await service.calculateETA({ originCity: 'Lagos', destinationCity: 'Abuja' }); + const result = await service.calculateETA({ + originCity: 'Lagos', + destinationCity: 'Abuja', + }); expect(result.estimatedHours).toBeDefined(); }); it('uses default buffer when carrier has no history', async () => { - const result = await service.calculateETA({ originCity: 'Lagos', destinationCity: 'Abuja' }); + const result = await service.calculateETA({ + originCity: 'Lagos', + destinationCity: 'Abuja', + }); expect(result.estimatedHours).toBeDefined(); }); }); diff --git a/backend/src/eta/eta.service.ts b/backend/src/eta/eta.service.ts index b58d3387..f46e26e5 100644 --- a/backend/src/eta/eta.service.ts +++ b/backend/src/eta/eta.service.ts @@ -10,27 +10,36 @@ import { ConfigService } from '@nestjs/config'; @Injectable() export class ETAService { constructor( - @InjectRepository(ETACalculation) private readonly etaRepo: Repository, + @InjectRepository(ETACalculation) + private readonly etaRepo: Repository, private readonly configService: ConfigService, ) {} async calculateETA(dto: CalculateETADto): Promise { const { originCity, destinationCity, carrierId } = dto; - const distanceKm = await this.estimateDistance(originCity, destinationCity); - const avgSpeed = this.configService.get('ETA_AVERAGE_SPEED_KMH') ?? ETA_CONFIG.averageSpeedKmh; + const distanceKm = this.estimateDistance(originCity, destinationCity); + const avgSpeed = + this.configService.get('ETA_AVERAGE_SPEED_KMH') ?? + ETA_CONFIG.averageSpeedKmh; const baseHours = distanceKm / avgSpeed; - const lateRate = await this.getCarrierLateRate(carrierId); + const lateRate = this.getCarrierLateRate(carrierId); const buffer = baseHours * lateRate; const totalHours = baseHours + buffer; const now = new Date(); const estimatedDate = new Date(now.getTime() + totalHours * 3600_000); - await this.etaRepo.save(this.etaRepo.create({ - originCity, destinationCity, carrierId: carrierId ?? null, - baseDurationHours: baseHours, bufferHours: buffer, - totalEstimatedHours: totalHours, estimatedDeliveryDate: estimatedDate, - confidenceLevel: this.getConfidenceLevel(carrierId), - })); + await this.etaRepo.save( + this.etaRepo.create({ + originCity, + destinationCity, + carrierId: carrierId ?? null, + baseDurationHours: baseHours, + bufferHours: buffer, + totalEstimatedHours: totalHours, + estimatedDeliveryDate: estimatedDate, + confidenceLevel: this.getConfidenceLevel(carrierId), + }), + ); return { estimatedHours: Math.round(totalHours * 100) / 100, @@ -39,21 +48,24 @@ export class ETAService { }; } - async recalculateForShipment(shipmentId: string, dto: CalculateETADto): Promise { + async recalculateForShipment( + shipmentId: string, + dto: CalculateETADto, + ): Promise { const eta = await this.calculateETA(dto); await this.etaRepo.update({ shipmentId }, { shipmentId }); return eta; } - private async estimateDistance(origin: string, destination: string): Promise { + private estimateDistance(origin: string, destination: string): number { const routeDistances: Record> = { - 'Lagos': { 'Abuja': 560, 'Ibadan': 120, 'Port Harcourt': 435 }, - 'Abuja': { 'Lagos': 560, 'Kano': 370, 'Port Harcourt': 510 }, + Lagos: { Abuja: 560, Ibadan: 120, 'Port Harcourt': 435 }, + Abuja: { Lagos: 560, Kano: 370, 'Port Harcourt': 510 }, }; return routeDistances[origin]?.[destination] ?? 300; } - private async getCarrierLateRate(carrierId?: string): Promise { + private getCarrierLateRate(carrierId?: string): number { if (!carrierId) return 0.15; return 0.0; } diff --git a/backend/src/location-updates/entities/location.entity.ts b/backend/src/location-updates/entities/location.entity.ts index 8fcfab66..b94de6bc 100644 --- a/backend/src/location-updates/entities/location.entity.ts +++ b/backend/src/location-updates/entities/location.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; @Entity('location_updates') @Index(['shipmentId', 'createdAt']) diff --git a/backend/src/location-updates/location-updates.service.ts b/backend/src/location-updates/location-updates.service.ts index b2468cbc..14f22410 100644 --- a/backend/src/location-updates/location-updates.service.ts +++ b/backend/src/location-updates/location-updates.service.ts @@ -1,4 +1,8 @@ -import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Location } from './entities/location.entity'; @@ -10,31 +14,55 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; export class LocationUpdatesService { constructor( @InjectRepository(Location) private readonly locRepo: Repository, - @InjectRepository(Shipment) private readonly shipmentRepo: Repository, + @InjectRepository(Shipment) + private readonly shipmentRepo: Repository, private readonly eventEmitter: EventEmitter2, ) {} - async recordLocation(shipmentId: string, carrierId: string, lat: number, lng: number, timestamp: number) { - const shipment = await this.shipmentRepo.findOne({ where: { id: shipmentId } }); + async recordLocation( + shipmentId: string, + carrierId: string, + lat: number, + lng: number, + timestamp: number, + ) { + const shipment = await this.shipmentRepo.findOne({ + where: { id: shipmentId }, + }); if (!shipment) throw new BadRequestException('Shipment not found'); - if (shipment.status !== ShipmentStatus.IN_TRANSIT) throw new BadRequestException('Shipment is not in transit'); - if (shipment.carrierId !== carrierId) throw new ForbiddenException('Only the assigned carrier can update location'); + if (shipment.status !== ShipmentStatus.IN_TRANSIT) + throw new BadRequestException('Shipment is not in transit'); + if (shipment.carrierId !== carrierId) + throw new ForbiddenException( + 'Only the assigned carrier can update location', + ); const location = this.locRepo.create({ shipmentId, lat, lng }); await this.locRepo.save(location); - const history = await this.locRepo.find({ where: { shipmentId }, order: { createdAt: 'ASC' } }); + const history = await this.locRepo.find({ + where: { shipmentId }, + order: { createdAt: 'ASC' }, + }); if (history.length > 10) { const toDelete = history.slice(0, history.length - 10); await this.locRepo.remove(toDelete); } - this.eventEmitter.emit('shipment.location.updated', { shipmentId, lat, lng, timestamp }); + this.eventEmitter.emit('shipment.location.updated', { + shipmentId, + lat, + lng, + timestamp, + }); return location; } async getHistory(shipmentId: string): Promise { - return this.locRepo.find({ where: { shipmentId }, order: { createdAt: 'ASC' } }); + return this.locRepo.find({ + where: { shipmentId }, + order: { createdAt: 'ASC' }, + }); } } diff --git a/backend/src/marketplace-search/dto/search-marketplace.dto.ts b/backend/src/marketplace-search/dto/search-marketplace.dto.ts index a860d69b..c1440145 100644 --- a/backend/src/marketplace-search/dto/search-marketplace.dto.ts +++ b/backend/src/marketplace-search/dto/search-marketplace.dto.ts @@ -1,17 +1,58 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsOptional, IsNumber, IsInt, Min, Max, IsEnum } from 'class-validator'; +import { + IsString, + IsOptional, + IsNumber, + IsInt, + Min, + Max, + IsEnum, +} from 'class-validator'; import { Type } from 'class-transformer'; import { CargoCategory } from '../../common/enums/cargo-category.enum'; export class SearchMarketplaceDto { @ApiPropertyOptional() @IsString() @IsOptional() origin?: string; @ApiPropertyOptional() @IsString() @IsOptional() destination?: string; - @ApiPropertyOptional() @IsNumber() @IsOptional() @Type(() => Number) minPrice?: number; - @ApiPropertyOptional() @IsNumber() @IsOptional() @Type(() => Number) maxPrice?: number; - @ApiPropertyOptional() @IsNumber() @IsOptional() @Type(() => Number) maxWeightKg?: number; - @ApiPropertyOptional({ enum: CargoCategory }) @IsEnum(CargoCategory) @IsOptional() cargoCategory?: CargoCategory; - @ApiPropertyOptional() @IsInt() @IsOptional() @Type(() => Number) postedWithinHours?: number; - @ApiPropertyOptional({ default: 1 }) @IsInt() @Min(1) @IsOptional() @Type(() => Number) page?: number = 1; - @ApiPropertyOptional({ default: 20 }) @IsInt() @Min(1) @Max(100) @IsOptional() @Type(() => Number) limit?: number = 20; - @ApiPropertyOptional({ enum: ['price', 'weight', 'postedAt'] }) @IsString() @IsOptional() sortBy?: string; + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + minPrice?: number; + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + maxPrice?: number; + @ApiPropertyOptional() + @IsNumber() + @IsOptional() + @Type(() => Number) + maxWeightKg?: number; + @ApiPropertyOptional({ enum: CargoCategory }) + @IsEnum(CargoCategory) + @IsOptional() + cargoCategory?: CargoCategory; + @ApiPropertyOptional() + @IsInt() + @IsOptional() + @Type(() => Number) + postedWithinHours?: number; + @ApiPropertyOptional({ default: 1 }) + @IsInt() + @Min(1) + @IsOptional() + @Type(() => Number) + page?: number = 1; + @ApiPropertyOptional({ default: 20 }) + @IsInt() + @Min(1) + @Max(100) + @IsOptional() + @Type(() => Number) + limit?: number = 20; + @ApiPropertyOptional({ enum: ['price', 'weight', 'postedAt'] }) + @IsString() + @IsOptional() + sortBy?: string; } diff --git a/backend/src/marketplace-search/marketplace-search.controller.ts b/backend/src/marketplace-search/marketplace-search.controller.ts index c0767a54..17e0ccee 100644 --- a/backend/src/marketplace-search/marketplace-search.controller.ts +++ b/backend/src/marketplace-search/marketplace-search.controller.ts @@ -10,7 +10,9 @@ export class MarketplaceSearchController { constructor(private readonly service: MarketplaceSearchService) {} @Get('search') - @ApiOperation({ summary: 'Search available shipments with filters and pagination' }) + @ApiOperation({ + summary: 'Search available shipments with filters and pagination', + }) search(@Query() query: SearchMarketplaceDto) { return this.service.search(query); } diff --git a/backend/src/marketplace-search/marketplace-search.service.ts b/backend/src/marketplace-search/marketplace-search.service.ts index 27621934..2b7fddad 100644 --- a/backend/src/marketplace-search/marketplace-search.service.ts +++ b/backend/src/marketplace-search/marketplace-search.service.ts @@ -8,29 +8,56 @@ import { SearchMarketplaceDto } from './dto/search-marketplace.dto'; @Injectable() export class MarketplaceSearchService { constructor( - @InjectRepository(Shipment) private readonly shipmentRepo: Repository, + @InjectRepository(Shipment) + private readonly shipmentRepo: Repository, ) {} async search(query: SearchMarketplaceDto) { - const { origin, destination, minPrice, maxPrice, maxWeightKg, cargoCategory, postedWithinHours, page = 1, limit = 20, sortBy = 'postedAt' } = query; + const { + origin, + destination, + minPrice, + maxPrice, + maxWeightKg, + cargoCategory, + postedWithinHours, + page = 1, + limit = 20, + sortBy = 'postedAt', + } = query; const skip = (page - 1) * limit; - const qb = this.shipmentRepo.createQueryBuilder('s') + const qb = this.shipmentRepo + .createQueryBuilder('s') .where('s.status = :status', { status: ShipmentStatus.PENDING }); - if (origin) qb.andWhere('s.origin ILIKE :origin', { origin: `%${origin}%` }); - if (destination) qb.andWhere('s.destination ILIKE :destination', { destination: `%${destination}%` }); - if (minPrice !== undefined) qb.andWhere('s.price >= :minPrice', { minPrice }); - if (maxPrice !== undefined) qb.andWhere('s.price <= :maxPrice', { maxPrice }); - if (maxWeightKg !== undefined) qb.andWhere('s.weightKg <= :maxWeightKg', { maxWeightKg }); - if (cargoCategory) qb.andWhere('s.cargoCategory = :cargoCategory', { cargoCategory }); + if (origin) + qb.andWhere('s.origin ILIKE :origin', { origin: `%${origin}%` }); + if (destination) + qb.andWhere('s.destination ILIKE :destination', { + destination: `%${destination}%`, + }); + if (minPrice !== undefined) + qb.andWhere('s.price >= :minPrice', { minPrice }); + if (maxPrice !== undefined) + qb.andWhere('s.price <= :maxPrice', { maxPrice }); + if (maxWeightKg !== undefined) + qb.andWhere('s.weightKg <= :maxWeightKg', { maxWeightKg }); + if (cargoCategory) + qb.andWhere('s.cargoCategory = :cargoCategory', { cargoCategory }); if (postedWithinHours) { const since = new Date(Date.now() - postedWithinHours * 3600_000); qb.andWhere('s.createdAt >= :since', { since }); } - const sortMap: Record = { price: 's.price', weight: 's.weightKg', postedAt: 's.createdAt' }; - qb.orderBy(sortMap[sortBy] ?? 's.createdAt', 'DESC').skip(skip).take(limit); + const sortMap: Record = { + price: 's.price', + weight: 's.weightKg', + postedAt: 's.createdAt', + }; + qb.orderBy(sortMap[sortBy] ?? 's.createdAt', 'DESC') + .skip(skip) + .take(limit); const [data, total] = await qb.getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; diff --git a/backend/src/onboarding/entities/onboarding-progress.entity.ts b/backend/src/onboarding/entities/onboarding-progress.entity.ts index cbeade08..a768eced 100644 --- a/backend/src/onboarding/entities/onboarding-progress.entity.ts +++ b/backend/src/onboarding/entities/onboarding-progress.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index, Unique } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + Unique, +} from 'typeorm'; export enum OnboardingStep { PROFILE_COMPLETE = 'PROFILE_COMPLETE', diff --git a/backend/src/onboarding/onboarding.controller.ts b/backend/src/onboarding/onboarding.controller.ts index 35fda89e..7370d1a6 100644 --- a/backend/src/onboarding/onboarding.controller.ts +++ b/backend/src/onboarding/onboarding.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Param } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { OnboardingService } from './onboarding.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; @@ -19,7 +19,10 @@ export class OnboardingController { @Post('step/:stepName') @ApiOperation({ summary: 'Mark an onboarding step as complete (idempotent)' }) - markStep(@CurrentUser() user: User, @Param('stepName') stepName: OnboardingStep) { + markStep( + @CurrentUser() user: User, + @Param('stepName') stepName: OnboardingStep, + ) { return this.onboardingService.markStep(user.id, stepName); } } diff --git a/backend/src/onboarding/onboarding.service.ts b/backend/src/onboarding/onboarding.service.ts index 14d8491d..a63b5965 100644 --- a/backend/src/onboarding/onboarding.service.ts +++ b/backend/src/onboarding/onboarding.service.ts @@ -1,21 +1,30 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { OnboardingProgress, OnboardingStep } from './entities/onboarding-progress.entity'; +import { + OnboardingProgress, + OnboardingStep, +} from './entities/onboarding-progress.entity'; @Injectable() export class OnboardingService { constructor( - @InjectRepository(OnboardingProgress) private readonly progressRepo: Repository, + @InjectRepository(OnboardingProgress) + private readonly progressRepo: Repository, ) {} async getProgress(userId: string): Promise { - const entries = await this.progressRepo.find({ where: { userId }, order: { completedAt: 'ASC' } }); - return entries.map(e => e.step); + const entries = await this.progressRepo.find({ + where: { userId }, + order: { completedAt: 'ASC' }, + }); + return entries.map((e) => e.step); } async markStep(userId: string, step: OnboardingStep): Promise { - const existing = await this.progressRepo.findOne({ where: { userId, step } }); + const existing = await this.progressRepo.findOne({ + where: { userId, step }, + }); if (existing) return; await this.progressRepo.save(this.progressRepo.create({ userId, step })); } diff --git a/backend/src/package/analytics/shipment-analytics.controller.ts b/backend/src/package/analytics/shipment-analytics.controller.ts index 1c8671c2..428130f7 100644 --- a/backend/src/package/analytics/shipment-analytics.controller.ts +++ b/backend/src/package/analytics/shipment-analytics.controller.ts @@ -3,7 +3,10 @@ import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { UserRole } from '../../common/enums/role.enum'; -import { ShipmentAnalyticsService, ShipmentAnalyticsResult } from './shipment-analytics.service'; +import { + ShipmentAnalyticsService, + ShipmentAnalyticsResult, +} from './shipment-analytics.service'; import { AnalyticsQueryDto } from './dto/analytics-query.dto'; @Controller('api/analytics') diff --git a/backend/src/package/analytics/shipment-analytics.service.spec.ts b/backend/src/package/analytics/shipment-analytics.service.spec.ts index 0c48fad1..6f87da82 100644 --- a/backend/src/package/analytics/shipment-analytics.service.spec.ts +++ b/backend/src/package/analytics/shipment-analytics.service.spec.ts @@ -3,7 +3,12 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ShipmentAnalyticsService } from './shipment-analytics.service'; import { Shipment } from '../../shipments/entities/shipment.entity'; -function makeQb(statusRows: object[], weeklyRows: object[], durationRow: object, routeRows: object[]) { +function makeQb( + statusRows: object[], + weeklyRows: object[], + durationRow: object, + routeRows: object[], +) { let cloneCallCount = 0; const results = [statusRows, weeklyRows, [durationRow], routeRows]; @@ -40,7 +45,10 @@ describe('ShipmentAnalyticsService', () => { beforeEach(async () => { const qb = makeQb( - [{ status: 'completed', count: '10' }, { status: 'pending', count: '5' }], + [ + { status: 'completed', count: '10' }, + { status: 'pending', count: '5' }, + ], [{ week: '2024-01-01', count: '4' }], { avg_hours: '48.5' }, [{ origin: 'Lagos', destination: 'Abuja', count: '8' }], @@ -71,10 +79,17 @@ describe('ShipmentAnalyticsService', () => { }); it('applies startDate and endDate filters when provided', async () => { - await service.getAnalytics({ startDate: '2024-01-01', endDate: '2024-12-31' }); + await service.getAnalytics({ + startDate: '2024-01-01', + endDate: '2024-12-31', + }); const qb = mockRepo.createQueryBuilder('s') as { andWhere: jest.Mock }; - expect(qb.andWhere).toHaveBeenCalledWith('s.created_at >= :startDate', { startDate: '2024-01-01' }); - expect(qb.andWhere).toHaveBeenCalledWith('s.created_at <= :endDate', { endDate: '2024-12-31' }); + expect(qb.andWhere).toHaveBeenCalledWith('s.created_at >= :startDate', { + startDate: '2024-01-01', + }); + expect(qb.andWhere).toHaveBeenCalledWith('s.created_at <= :endDate', { + endDate: '2024-12-31', + }); }); it('returns null avgDeliveryDurationHours when no rows', async () => { diff --git a/backend/src/package/analytics/shipment-analytics.service.ts b/backend/src/package/analytics/shipment-analytics.service.ts index 084b3e86..5678d2b4 100644 --- a/backend/src/package/analytics/shipment-analytics.service.ts +++ b/backend/src/package/analytics/shipment-analytics.service.ts @@ -18,11 +18,15 @@ export class ShipmentAnalyticsService { private readonly shipmentRepo: Repository, ) {} - async getAnalytics(query: AnalyticsQueryDto): Promise { + async getAnalytics( + query: AnalyticsQueryDto, + ): Promise { const baseQb = this.shipmentRepo.createQueryBuilder('s'); if (query.startDate) { - baseQb.andWhere('s.created_at >= :startDate', { startDate: query.startDate }); + baseQb.andWhere('s.created_at >= :startDate', { + startDate: query.startDate, + }); } if (query.endDate) { baseQb.andWhere('s.created_at <= :endDate', { endDate: query.endDate }); @@ -50,7 +54,7 @@ export class ShipmentAnalyticsService { const durationRow: { avg_hours: string | null } | undefined = await baseQb .clone() .select( - "EXTRACT(EPOCH FROM AVG(s.actual_delivery_date - s.pickup_date)) / 3600", + 'EXTRACT(EPOCH FROM AVG(s.actual_delivery_date - s.pickup_date)) / 3600', 'avg_hours', ) .andWhere('s.actual_delivery_date IS NOT NULL') @@ -78,9 +82,10 @@ export class ShipmentAnalyticsService { week: r.week, count: Number(r.count), })), - avgDeliveryDurationHours: durationRow?.avg_hours != null - ? parseFloat(durationRow.avg_hours) - : null, + avgDeliveryDurationHours: + durationRow?.avg_hours != null + ? parseFloat(durationRow.avg_hours) + : null, topRoutes: routeRows.map((r) => ({ origin: r.origin, destination: r.destination, diff --git a/backend/src/package/document-integrity/document-integrity.controller.ts b/backend/src/package/document-integrity/document-integrity.controller.ts index 7ba3e886..f29dd09e 100644 --- a/backend/src/package/document-integrity/document-integrity.controller.ts +++ b/backend/src/package/document-integrity/document-integrity.controller.ts @@ -1,6 +1,9 @@ import { Controller, Param, Post, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; -import { DocumentIntegrityService, IntegrityResult } from './document-integrity.service'; +import { + DocumentIntegrityService, + IntegrityResult, +} from './document-integrity.service'; @Controller('api/documents') @UseGuards(JwtAuthGuard) diff --git a/backend/src/package/document-integrity/document-integrity.service.ts b/backend/src/package/document-integrity/document-integrity.service.ts index e0468107..4d8f604f 100644 --- a/backend/src/package/document-integrity/document-integrity.service.ts +++ b/backend/src/package/document-integrity/document-integrity.service.ts @@ -1,8 +1,4 @@ -import { - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs'; diff --git a/backend/src/reputation-calculator/reputation-calculator.service.ts b/backend/src/reputation-calculator/reputation-calculator.service.ts index 31c638a1..09dd264f 100644 --- a/backend/src/reputation-calculator/reputation-calculator.service.ts +++ b/backend/src/reputation-calculator/reputation-calculator.service.ts @@ -9,33 +9,50 @@ import { ShipmentStatus } from '../common/enums/shipment-status.enum'; export class ReputationCalculatorService { constructor( @InjectRepository(Review) private readonly reviewRepo: Repository, - @InjectRepository(Shipment) private readonly shipmentRepo: Repository, + @InjectRepository(Shipment) + private readonly shipmentRepo: Repository, ) {} async calculateScore(carrierId: string) { - const reviews = await this.reviewRepo.find({ where: { revieweeId: carrierId } }); + const reviews = await this.reviewRepo.find({ + where: { revieweeId: carrierId }, + }); const shipments = await this.shipmentRepo.find({ where: { carrierId } }); - const avgRating = reviews.length > 0 - ? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length - : 0; + const avgRating = + reviews.length > 0 + ? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length + : 0; - const onTimeCount = shipments.filter(s => - s.actualDeliveryDate && s.estimatedDeliveryDate && - s.actualDeliveryDate <= s.estimatedDeliveryDate + const onTimeCount = shipments.filter( + (s) => + s.actualDeliveryDate && + s.estimatedDeliveryDate && + s.actualDeliveryDate <= s.estimatedDeliveryDate, ).length; const onTimePct = shipments.length > 0 ? onTimeCount / shipments.length : 0; - const completedCount = shipments.filter(s => - s.status === ShipmentStatus.COMPLETED || s.status === ShipmentStatus.DELIVERED + const completedCount = shipments.filter( + (s) => + s.status === ShipmentStatus.COMPLETED || + s.status === ShipmentStatus.DELIVERED, ).length; - const completionRate = shipments.length > 0 ? completedCount / shipments.length : 0; + const completionRate = + shipments.length > 0 ? completedCount / shipments.length : 0; const ratingComponent = (avgRating / 5) * 500; const punctualityComponent = onTimePct * 300; const reliabilityComponent = completionRate * 200; - const compositeScore = Math.min(ratingComponent + punctualityComponent + reliabilityComponent, 1000); + const compositeScore = Math.min( + ratingComponent + punctualityComponent + reliabilityComponent, + 1000, + ); - return { ratingComponent: Math.round(ratingComponent), punctualityComponent: Math.round(punctualityComponent * 100) / 100, reliabilityComponent: Math.round(reliabilityComponent * 100) / 100, compositeScore: Math.round(compositeScore) }; + return { + ratingComponent: Math.round(ratingComponent), + punctualityComponent: Math.round(punctualityComponent * 100) / 100, + reliabilityComponent: Math.round(reliabilityComponent * 100) / 100, + compositeScore: Math.round(compositeScore), + }; } } diff --git a/backend/src/request-logger/request-logger.middleware.ts b/backend/src/request-logger/request-logger.middleware.ts index 5c90430d..ae46b1d8 100644 --- a/backend/src/request-logger/request-logger.middleware.ts +++ b/backend/src/request-logger/request-logger.middleware.ts @@ -2,6 +2,10 @@ import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; import { v4 as uuidv4 } from 'uuid'; +interface RequestWithCorrelation extends Request { + correlationId: string; +} + const SENSITIVE_HEADERS = ['authorization', 'cookie', 'x-api-key']; @Injectable() @@ -10,7 +14,7 @@ export class RequestLoggerMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction): void { const correlationId = uuidv4(); - (req as any).correlationId = correlationId; + (req as RequestWithCorrelation).correlationId = correlationId; res.setHeader('X-Correlation-Id', correlationId); const redactedHeaders = { ...req.headers }; @@ -23,8 +27,15 @@ export class RequestLoggerMiddleware implements NestMiddleware { res.on('finish', () => { const duration = Date.now() - start; - const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'log'; - this.logger[level](`<-- ${res.statusCode} ${req.method} ${req.path} ${duration}ms [${correlationId}]`); + const level = + res.statusCode >= 500 + ? 'error' + : res.statusCode >= 400 + ? 'warn' + : 'log'; + this.logger[level]( + `<-- ${res.statusCode} ${req.method} ${req.path} ${duration}ms [${correlationId}]`, + ); }); next(); diff --git a/backend/src/reviews/dto/review-response.dto.ts b/backend/src/reviews/dto/review-response.dto.ts new file mode 100644 index 00000000..5ae9d8c0 --- /dev/null +++ b/backend/src/reviews/dto/review-response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ReviewResponseDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ format: 'uuid' }) + shipmentId: string; + + @ApiProperty({ format: 'uuid' }) + reviewerId: string; + + @ApiProperty({ format: 'uuid' }) + revieweeId: string; + + @ApiProperty({ minimum: 1, maximum: 5 }) + rating: number; + + @ApiPropertyOptional({ type: String, nullable: true }) + comment: string | null; + + @ApiProperty() + createdAt: Date; +} diff --git a/backend/src/route-calculator/route-calculator.module.ts b/backend/src/route-calculator/route-calculator.module.ts index 7e593496..ae8537fe 100644 --- a/backend/src/route-calculator/route-calculator.module.ts +++ b/backend/src/route-calculator/route-calculator.module.ts @@ -8,4 +8,4 @@ import { ShipmentsModule } from '../shipments/shipments.module'; providers: [RouteCalculatorService], controllers: [RouteCalculatorController], }) -export class RouteCalculatorModule {} \ No newline at end of file +export class RouteCalculatorModule {} diff --git a/backend/src/route-calculator/route-calculator.service.spec.ts b/backend/src/route-calculator/route-calculator.service.spec.ts index 0f76ca9e..b7c5f07d 100644 --- a/backend/src/route-calculator/route-calculator.service.spec.ts +++ b/backend/src/route-calculator/route-calculator.service.spec.ts @@ -55,4 +55,4 @@ describe('RouteCalculatorService', () => { expect(footprint).toBe(expectedFootprint); }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/route-calculator/route-calculator.service.ts b/backend/src/route-calculator/route-calculator.service.ts index 5b46cda3..87d32f3c 100644 --- a/backend/src/route-calculator/route-calculator.service.ts +++ b/backend/src/route-calculator/route-calculator.service.ts @@ -61,4 +61,4 @@ export class RouteCalculatorService { private degreesToRadians(degrees: number): number { return degrees * (Math.PI / 180); } -} \ No newline at end of file +} diff --git a/backend/src/stellar-escrow/entities/escrow-transaction.entity.ts b/backend/src/stellar-escrow/entities/escrow-transaction.entity.ts index c178be70..1e703b2e 100644 --- a/backend/src/stellar-escrow/entities/escrow-transaction.entity.ts +++ b/backend/src/stellar-escrow/entities/escrow-transaction.entity.ts @@ -1,4 +1,9 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; @Entity('escrow_transactions') export class EscrowTransaction { diff --git a/backend/src/stellar-escrow/stellar-escrow.service.ts b/backend/src/stellar-escrow/stellar-escrow.service.ts index 9c1e158d..91fd06b3 100644 --- a/backend/src/stellar-escrow/stellar-escrow.service.ts +++ b/backend/src/stellar-escrow/stellar-escrow.service.ts @@ -6,21 +6,42 @@ export class StellarEscrowBridgeService { constructor() {} - async fundEscrow(shipmentId: string, amount: number, tokenAddress: string): Promise<{ txHash: string; ledgerNumber: number }> { + fundEscrow( + shipmentId: string, + amount: number, + tokenAddress: string, + ): Promise<{ txHash: string; ledgerNumber: number }> { if (!shipmentId || amount <= 0) { - throw new BadGatewayException('Stellar contract call failed: invalid parameters'); + throw new BadGatewayException( + 'Stellar contract call failed: invalid parameters', + ); } - this.logger.log(`Funding escrow for shipment ${shipmentId}: ${amount} ${tokenAddress}`); - return { txHash: 'stellar-tx-' + Date.now(), ledgerNumber: 12345 }; + this.logger.log( + `Funding escrow for shipment ${shipmentId}: ${amount} ${tokenAddress}`, + ); + return Promise.resolve({ + txHash: 'stellar-tx-' + Date.now(), + ledgerNumber: 12345, + }); } - async releaseEscrow(shipmentId: string): Promise<{ txHash: string; ledgerNumber: number }> { + releaseEscrow( + shipmentId: string, + ): Promise<{ txHash: string; ledgerNumber: number }> { this.logger.log(`Releasing escrow for shipment ${shipmentId}`); - return { txHash: 'stellar-tx-' + Date.now(), ledgerNumber: 12346 }; + return Promise.resolve({ + txHash: 'stellar-tx-' + Date.now(), + ledgerNumber: 12346, + }); } - async refundEscrow(shipmentId: string): Promise<{ txHash: string; ledgerNumber: number }> { + refundEscrow( + shipmentId: string, + ): Promise<{ txHash: string; ledgerNumber: number }> { this.logger.log(`Refunding escrow for shipment ${shipmentId}`); - return { txHash: 'stellar-tx-' + Date.now(), ledgerNumber: 12347 }; + return Promise.resolve({ + txHash: 'stellar-tx-' + Date.now(), + ledgerNumber: 12347, + }); } } diff --git a/backend/src/swagger-helpers/swagger-helpers.ts b/backend/src/swagger-helpers/swagger-helpers.ts index f4d52333..96fcb0d1 100644 --- a/backend/src/swagger-helpers/swagger-helpers.ts +++ b/backend/src/swagger-helpers/swagger-helpers.ts @@ -1,14 +1,31 @@ import { applyDecorators, Type } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody, ApiConsumes, ApiExtraModels, ApiOkResponse, ApiOperation, ApiProperty, ApiPropertyOptional, ApiResponse, ApiUnauthorizedResponse, getSchemaPath } from '@nestjs/swagger'; -import { ReferenceObject, SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiExtraModels, + ApiOkResponse, + ApiUnauthorizedResponse, + getSchemaPath, +} from '@nestjs/swagger'; -export function ApiPaginatedResponse>(model: TModel) { +export function ApiPaginatedResponse>( + model: TModel, +) { return applyDecorators( ApiExtraModels(model), ApiOkResponse({ schema: { allOf: [ - { properties: { data: { type: 'array', items: { $ref: getSchemaPath(model) } }, total: { type: 'number' }, page: { type: 'number' }, limit: { type: 'number' }, totalPages: { type: 'number' } } }, + { + properties: { + data: { type: 'array', items: { $ref: getSchemaPath(model) } }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' }, + totalPages: { type: 'number' }, + }, + }, ], }, }), @@ -16,7 +33,10 @@ export function ApiPaginatedResponse>(model: TModel } export function ApiJwtAuth() { - return applyDecorators(ApiBearerAuth(), ApiUnauthorizedResponse({ description: 'Unauthorized' })); + return applyDecorators( + ApiBearerAuth(), + ApiUnauthorizedResponse({ description: 'Unauthorized' }), + ); } export function ApiFileUpload(fieldName = 'file') { diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index eddfaf07..c1790198 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -94,4 +94,4 @@ export class User { @OneToMany(() => TwoFactorRecovery, (recovery) => recovery.user) recoveryCodes: TwoFactorRecovery[]; -} \ No newline at end of file +} diff --git a/backend/src/webhooks/dto/create-webhook.dto.ts b/backend/src/webhooks/dto/create-webhook.dto.ts index 19823860..6f8618d9 100644 --- a/backend/src/webhooks/dto/create-webhook.dto.ts +++ b/backend/src/webhooks/dto/create-webhook.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; +import { + IsArray, + IsOptional, + IsString, + IsUrl, + MaxLength, +} from 'class-validator'; export class CreateWebhookDto { @ApiProperty({ example: 'https://partner.example.com/webhooks/freightflow' }) diff --git a/backend/src/webhooks/dto/webhook-response.dto.ts b/backend/src/webhooks/dto/webhook-response.dto.ts new file mode 100644 index 00000000..d8310c39 --- /dev/null +++ b/backend/src/webhooks/dto/webhook-response.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class WebhookResponseDto { + @ApiProperty({ format: 'uuid' }) + id: string; + + @ApiProperty({ format: 'uuid' }) + userId: string; + + @ApiProperty({ example: 'https://example.com/webhook' }) + url: string; + + @ApiPropertyOptional({ type: [String], nullable: true }) + events: string[] | null; + + @ApiProperty() + active: boolean; + + @ApiPropertyOptional({ type: String, nullable: true }) + lastDeliveryStatus: string | null; + + @ApiPropertyOptional({ type: Date, nullable: true }) + lastDeliveryAt: Date | null; + + @ApiProperty() + createdAt: Date; +} diff --git a/backend/src/webhooks/webhooks.service.ts b/backend/src/webhooks/webhooks.service.ts index a7fe6e64..88cc8a1e 100644 --- a/backend/src/webhooks/webhooks.service.ts +++ b/backend/src/webhooks/webhooks.service.ts @@ -75,7 +75,10 @@ export class WebhooksService { } async deliverShipmentStatusChange(event: ShipmentEvent): Promise { - const webhooks = await this.findAllForUser(event.shipment.shipperId, 'shipment.status_changed'); + const webhooks = await this.findAllForUser( + event.shipment.shipperId, + 'shipment.status_changed', + ); if (webhooks.length === 0) { return; @@ -129,7 +132,10 @@ export class WebhooksService { }); return; } catch (error) { - lastError = error instanceof Error ? error.message : 'Unknown webhook delivery error'; + lastError = + error instanceof Error + ? error.message + : 'Unknown webhook delivery error'; if (attempt === 3) { this.logger.warn( `Failed to deliver webhook ${webhook.id} after 3 attempts: ${lastError}`, diff --git a/build_errors.txt b/build_errors.txt new file mode 100644 index 0000000000000000000000000000000000000000..3d898a934159be501debb7cc4dcb8b632faef8af GIT binary patch literal 10644 zcmeHMTW=ai6h6+{(b&QIgja?at*wN_gi}`2>27(4r+Hd}vPx_hb3ly>p5A zy7=_v*J=eX`l9nap#bEkI) z-wo%-CwRN-Y_F=w zBK#EoAUbr6{bgLAp>GUK5+_ag^b!~2&%S$;Jjlx@@00oQK7s5pH1UYN52-{oQS<`y z(6`j~3^~j^9N_K>&w366sw?e!A~z1e?%zDh~so60N7Ph^Q8 z(YGv~bi8U6Gui-tsz3%&Pp^;#^Z2Wl!(TPnsq?JDU^Ou1kwb|}A@X|oFrwrcMx4>G zdU`G^xm>DFzR4d=`GD^ZEUDt^A!=n@KFXeXJSyh*rhP)@WDX?Igx+Dac+Uuq;6b9S zHNmB2L#`@Kc$I9#T=FxIplys4>s!up?WB@z6Zsw2nX}}l3_HHZ>>9g7{>0e8%bW7f zYB-H?yXd{ecad#HV_xnMRb0o0K-dl9#Wj;P0xN~QzQwDBLz7juia0ROSIf3TR1CIW z_(9&udxun_jn;BKC6^MlT|6V--Qt%x@46OIuPo1=59ndVKqkwo7Rb_`GZRz+)<3T? zOV`yVElBW{g=RkMF;*$miyUBci?z)F-u7#h))TeI0RALC(7s)EOKeM)oV zy!Aq*r}6vc7FUaJfFfdS48*g-3s#jK%6qVqYsb(bvS`j%|70;f*FaXXZAmkH1A4R8 z@>b-Hir0Zhc}x>9^q3Q}F1ie2cpm z8JJwgs^akIc`+M#c~8{?i!tK5h&-4gFU^qwshdY+$gG$ni|51Ok1Rf{PuKNjSgjq}n2e_fti{RjRb2B&RPY9O6FkrH=Z$2< z{91A!C$XxNMOjU(x68<4j`B4MCHk`*ebe{Z)6?4Y^i(ZnuRkHw^G@z>O4ui~!P^~F zWPVMZzgwYg>D|gpym^J)15b5nch(-oS{dTyJ>_>n++Wbc)Py@C?yGppz+T>mQwH`} zojuyV#?w)xNz7e~oiYXe7HwI?X++fP>2s-%p{EFZg;RKouRXEVO_BKW*~5MJjf*IG zYFUgC1$sW3jjv#3I%;=PsH#kIIcSr}ANXjrc(%#+HHtRt(sI*x76EMQ`zm+aDBww? z>a;BskU5y!MdHBPnU2sb4q|`?k15DXJrwqMfyL95T8WmVI$T|QNHS$5$26?v5TA$)>q3JhvuKN z{{m+OD?@Z_m1-;0Iw}j^w@`QTUx#z7Z#SqP-l1w8y86}LUtk1l)bo$jBdl)iEM%n^ zc^5N#rsXS^VBfl}7`<9&Xnt(J_6bq7*nNcElQT z*!jSmky?cBZ4lvpe_mv5Z>>znGW(ohww`;i_C8PNjWMA-vMZc-VAkbrAz~ApXYJJ3 S_}_d?#CjEniZoLY8~*~cOlI-` literal 0 HcmV?d00001 diff --git a/build_errors2.txt b/build_errors2.txt new file mode 100644 index 0000000000000000000000000000000000000000..95ae549d5489f13dacda36706c2e4d7c9c6f14d8 GIT binary patch literal 4084 zcmd59xR~U3I!W5Mhzbc8VD*rfiau5)MCG~UC_qJPgl>G+u3^y zThQVMX_mcv@9fOnGiT0SethpqUmDVs7}pnEk*wpoCx@~okEJU^Nu@3oNnq8Lw)7^( zG1i-~IKxU^y=0i-+mB$qKK4_^xF*Ym^K1ERB;`Ev65gQS6xpS@h0BVbBRP<_@K}>7 z^0bV(av&oa=srsFRlZ3Hwr%7T$z8dFb&iL+j~3=K`KY$Xh||HZFQ4E&2wnL!pr+6O)@eKWy)6Ii{2HQyW}1LoI&MTYC4zS-AtN1q1zZd1n{_+XCN1v}@T z8E_XbupS{>R-*~;OL(edCkf*5i8c=~cOTcBY+&w4_BHPo{w9jwHs0>&o{r$fao`00 zEBTW#V(vy>J|J2j9%B7jX6JHSe#|Hu|A^TJ@L>g8$T~%ZcYp`kVK(8Nb=|{S1}>5( zbzpjoi}mN){FwjK`p98&b-mZ&J3}U{bxRiD^-$g`JKi7;v1PVf=w0^T08eeq^^^ma zEBCc6r^@h=yv8U|_E@f4@b5Zp2bnts*i#(Z=g)g!;QRWh{O^ukylL>Ep)AYo2iR$1zx>3j{v$w1!mCVPiXngLSHA|sC ze(l$lZF!2atpxM4Xxpl+qL)K$b3JT|Gy(=CbZmy|5LGH2cih0U(<=5gm4eDn-Q$?t znA9#Zp6WplG1fL=OBRe^dx|@in3bQ5MqPWR@eF0wKE%RTsIF06{7>*ULN1X!#g#yB z6U1X?)M92C2UXbB$kbNWhc&U?#$ul4kqPVO>o^2zia!sV?N!9 z@pV;G?5+2REc>{JpX+&QJGsIB^_jAVpJSdJ%fy}c>`tmXm4Vn&O^7h}M1|%vF&zRw z+K_!bi$WC%E5Y;AVaQ%)hL$C{;1OYruKMKnRfMm#NOlpyP>t~Q7JhGCm-)qW!#dCU zt?1M+1{U8#UKWilg6;4Ba;^CLnq09wZwJ@-J=F$({yx_{B1K%|SB>TJ`@FL^OP9_V zk2XhZVEpB;m14dX%@*-4oU>@RgM-zaYm<5xR-%lq_Q>#DzwW=ho9W#N>fHVZZ1a1+ c$hJl7I8ilyskuz5n&tOr^~-Z4WC&UE69w6Mga7~l literal 0 HcmV?d00001 diff --git a/build_errors3.txt b/build_errors3.txt new file mode 100644 index 0000000000000000000000000000000000000000..edf01b2de5928ef0401806ba236936d1a4826a87 GIT binary patch literal 1692 zcmd6oOK;Oa5Xa|?#3$gg>VXtN9SQ{0@Td^9QV$?rw}OyK($wH2m17FN@X>+ae>Q6y zi6S8`XtnX|?Cd=L^T?0yOIz90W|pzPvV)D;SM1pK?5-_rZIw-I$j;fPNJP4VUvR2y zr+1ejFWxzK6R6B;IMQ|#UE_`3+t9aIzvu6@{DAJ{%Pw!Exx(M7m56>ycd$418l5B7 zuE%iMF12zm{N0&rsI5VI0B7dj*3bbAR(=j9L~1y~qd-FR3T}XEOgi-XNsGsTD1$vk zcFMOw_A_63oBOLlYh~Z$(_`0IBELjtjJLs&&GASl$y|qbmA!?XvAwnE{~U?jV?E?` z$?G+DDT5z54ck-7=hi=Id5^Uf8ZuwWvv2mlEO`_*OPDDuHMq?2x*``x;Bn#+B&qJJ zI)BbxP2DJ~6EHnt&&Z?hw!L~T?t+NPpYuINca0~?_1vzZ_1NCo4Zg3iBW&gE0hOk1 zIpb8oTY3#7PZif8HC3`2dkK|8b-o^;KX%2DS73}wI282Z3`|y7T6i^1v6ZAFcKX#; zC*4D;-Zl+(anTsu01D4^=QUUy|qo$_kXbQA>Sn@O@)l_#glDhciQ;+6K~XVBl0oI zgnR#07r&-y?bY~z`SysK`~;_QjwVy8ELGGwIVg!rGey%-yqZ|J z^o7l2S$LE)i&kR3k{8Ltgl3$!;znGPT+Bm12kEApfWhr(+N$45JXWP>Zt9;!bmp}p Ggnj}B;|RF` literal 0 HcmV?d00001 From 43a68b7a1ec040cbbd44a68b67505c724b758d02 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 27 Jun 2026 17:44:05 +0100 Subject: [PATCH 2/6] feat: add response DTOs, BullMQ queues, API keys, security headers, Docker, CI workflows closes #969 closes #962 closes #961 closes #960 --- .github/workflows/backend.yml | 41 +++++++++++ .github/workflows/frontend.yml | 43 ++++++++++++ backend/Dockerfile | 19 +++++ .../src/admin-stats/admin-stats.service.ts | 5 +- backend/src/admin/admin.service.spec.ts | 10 +-- backend/src/api-keys/api-key.entity.ts | 39 +++++++++++ backend/src/api-keys/api-keys.controller.ts | 65 +++++++++++++++++ backend/src/api-keys/api-keys.module.ts | 14 ++++ backend/src/api-keys/api-keys.service.ts | 43 ++++++++++++ backend/src/api-keys/guards/api-key.guard.ts | 29 ++++++++ backend/src/app.module.ts | 10 +++ .../audit-log/audit-log.controller.spec.ts | 7 +- backend/src/audit-log/audit-log.controller.ts | 9 ++- .../src/bid-expiry/bid-expiry.service.spec.ts | 2 +- .../queues/processors/email-send.processor.ts | 24 +++++++ .../processors/pdf-generate.processor.ts | 26 +++++++ .../processors/stellar-anchor.processor.ts | 24 +++++++ backend/src/queues/queues.controller.ts | 27 ++++++++ backend/src/queues/queues.module.ts | 41 +++++++++++ backend/src/queues/queues.service.ts | 44 ++++++++++++ .../schedulers/stuck-shipment.scheduler.ts | 22 ++++++ .../schedulers/temp-cleanup.scheduler.ts | 20 ++++++ backend/src/webhooks/webhooks.controller.ts | 2 + docker-compose.prod.yml | 69 +++++++++++++++++++ frontend/Dockerfile | 23 +++++++ frontend/next.config.ts | 36 +++++++++- 26 files changed, 682 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/backend.yml create mode 100644 .github/workflows/frontend.yml create mode 100644 backend/Dockerfile create mode 100644 backend/src/api-keys/api-key.entity.ts create mode 100644 backend/src/api-keys/api-keys.controller.ts create mode 100644 backend/src/api-keys/api-keys.module.ts create mode 100644 backend/src/api-keys/api-keys.service.ts create mode 100644 backend/src/api-keys/guards/api-key.guard.ts create mode 100644 backend/src/queues/processors/email-send.processor.ts create mode 100644 backend/src/queues/processors/pdf-generate.processor.ts create mode 100644 backend/src/queues/processors/stellar-anchor.processor.ts create mode 100644 backend/src/queues/queues.controller.ts create mode 100644 backend/src/queues/queues.module.ts create mode 100644 backend/src/queues/queues.service.ts create mode 100644 backend/src/queues/schedulers/stuck-shipment.scheduler.ts create mode 100644 backend/src/queues/schedulers/temp-cleanup.scheduler.ts create mode 100644 docker-compose.prod.yml create mode 100644 frontend/Dockerfile diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 00000000..ecaa923a --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,41 @@ +name: Backend CI + +on: + pull_request: + branches: ["**"] + paths: + - "backend/**" + push: + branches: [main] + paths: + - "backend/**" + +jobs: + backend: + name: Backend (NestJS) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: backend + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Test + run: npm run test -- --passWithNoTests diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 00000000..ab6a269c --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,43 @@ +name: Frontend CI + +on: + pull_request: + branches: ["**"] + paths: + - "frontend/**" + push: + branches: [main] + paths: + - "frontend/**" + +jobs: + frontend: + name: Frontend (Next.js) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_API_URL: http://localhost:3000 + + - name: Test + run: npm run test -- --passWithNoTests diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..b76837b5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine AS base +WORKDIR /app + +FROM base AS deps +COPY package*.json ./ +RUN npm ci --only=production + +FROM base AS build +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM base AS runtime +ENV NODE_ENV=production +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +EXPOSE 3000 +CMD ["node", "dist/main"] diff --git a/backend/src/admin-stats/admin-stats.service.ts b/backend/src/admin-stats/admin-stats.service.ts index 00bf5844..de0174e0 100644 --- a/backend/src/admin-stats/admin-stats.service.ts +++ b/backend/src/admin-stats/admin-stats.service.ts @@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../users/entities/user.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; -import { UserRole } from '../common/enums/role.enum'; import { ShipmentStatus } from '../common/enums/shipment-status.enum'; @Injectable() @@ -34,7 +33,7 @@ export class AdminStatsService { .createQueryBuilder('shipment') .select('SUM(shipment.price)', 'total') .where('shipment.status = :status', { status: ShipmentStatus.COMPLETED }) - .getRawOne(); + .getRawOne<{ total: string | null }>(); const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); @@ -67,7 +66,7 @@ export class AdminStatsService { return { totalUsersByRole, totalShipmentsByStatus, - totalPlatformRevenue: totalPlatformRevenue.total || 0, + totalPlatformRevenue: totalPlatformRevenue?.total ?? 0, newUsersThisWeek, shipmentsCreatedThisWeek, openDisputeCount, diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts index 53c1fbc2..52d2af43 100644 --- a/backend/src/admin/admin.service.spec.ts +++ b/backend/src/admin/admin.service.spec.ts @@ -11,8 +11,8 @@ import { REQUEST } from '@nestjs/core'; describe('AdminService', () => { let service: AdminService; let cacheService: CarrierCacheService; - let userRepository: any; - let response: any; + let userRepository: { findAndCount: jest.Mock }; + let response: { header: jest.Mock }; const mockUserRepository = { findAndCount: jest.fn(), @@ -57,8 +57,10 @@ describe('AdminService', () => { service = module.get(AdminService); cacheService = module.get(CarrierCacheService); - userRepository = module.get(getRepositoryToken(User)); - response = module.get(REQUEST).res; + userRepository = module.get<{ findAndCount: jest.Mock }>( + getRepositoryToken(User), + ); + response = module.get<{ res: { header: jest.Mock } }>(REQUEST).res; }); it('should be defined', () => { diff --git a/backend/src/api-keys/api-key.entity.ts b/backend/src/api-keys/api-key.entity.ts new file mode 100644 index 00000000..104149ad --- /dev/null +++ b/backend/src/api-keys/api-key.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../users/entities/user.entity'; + +@Entity('api_keys') +export class ApiKey { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'key_hash' }) + keyHash: string; + + @Column() + name: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @Column({ name: 'last_used_at', nullable: true, type: 'timestamptz' }) + lastUsedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/api-keys/api-keys.controller.ts b/backend/src/api-keys/api-keys.controller.ts new file mode 100644 index 00000000..2674e032 --- /dev/null +++ b/backend/src/api-keys/api-keys.controller.ts @@ -0,0 +1,65 @@ +import { + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Post, + Body, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { ApiKeysService } from './api-keys.service'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '../users/entities/user.entity'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../common/enums/role.enum'; + +class CreateApiKeyDto { + name: string; +} + +@ApiTags('api-keys') +@ApiBearerAuth() +@Controller('api-keys') +@UseGuards(RolesGuard) +@Roles(UserRole.SHIPPER, UserRole.CARRIER, UserRole.ADMIN) +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + @Post() + @ApiOperation({ summary: 'Create a new API key' }) + @ApiResponse({ + status: 201, + description: + 'API key created — store the raw key, it will not be shown again', + }) + async create(@CurrentUser() user: User, @Body() dto: CreateApiKeyDto) { + return this.apiKeysService.create(user.id, dto.name); + } + + @Get() + @ApiOperation({ summary: 'List my API keys' }) + @ApiResponse({ status: 200 }) + findAll(@CurrentUser() user: User) { + return this.apiKeysService.findAllForUser(user.id); + } + + @Delete(':id') + @HttpCode(204) + @ApiOperation({ summary: 'Revoke an API key' }) + @ApiResponse({ status: 204 }) + async revoke( + @CurrentUser() user: User, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.apiKeysService.revoke(user.id, id); + } +} diff --git a/backend/src/api-keys/api-keys.module.ts b/backend/src/api-keys/api-keys.module.ts new file mode 100644 index 00000000..3ca6380b --- /dev/null +++ b/backend/src/api-keys/api-keys.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApiKey } from './api-key.entity'; +import { ApiKeysService } from './api-keys.service'; +import { ApiKeysController } from './api-keys.controller'; +import { ApiKeyGuard } from './guards/api-key.guard'; + +@Module({ + imports: [TypeOrmModule.forFeature([ApiKey])], + controllers: [ApiKeysController], + providers: [ApiKeysService, ApiKeyGuard], + exports: [ApiKeysService, ApiKeyGuard], +}) +export class ApiKeysModule {} diff --git a/backend/src/api-keys/api-keys.service.ts b/backend/src/api-keys/api-keys.service.ts new file mode 100644 index 00000000..ce08970e --- /dev/null +++ b/backend/src/api-keys/api-keys.service.ts @@ -0,0 +1,43 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as crypto from 'crypto'; +import { ApiKey } from './api-key.entity'; + +@Injectable() +export class ApiKeysService { + constructor( + @InjectRepository(ApiKey) + private readonly repo: Repository, + ) {} + + async create( + userId: string, + name: string, + ): Promise<{ key: string; apiKey: ApiKey }> { + const rawKey = `ff_${crypto.randomBytes(32).toString('hex')}`; + const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex'); + const apiKey = this.repo.create({ userId, name, keyHash }); + await this.repo.save(apiKey); + return { key: rawKey, apiKey }; + } + + async findAllForUser(userId: string): Promise { + return this.repo.find({ where: { userId, isActive: true } }); + } + + async revoke(userId: string, id: string): Promise { + const key = await this.repo.findOne({ where: { id, userId } }); + if (!key) throw new NotFoundException('API key not found'); + await this.repo.update(id, { isActive: false }); + } + + async validateKey(rawKey: string): Promise { + const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex'); + const key = await this.repo.findOne({ where: { keyHash, isActive: true } }); + if (key) { + await this.repo.update(key.id, { lastUsedAt: new Date() }); + } + return key ?? null; + } +} diff --git a/backend/src/api-keys/guards/api-key.guard.ts b/backend/src/api-keys/guards/api-key.guard.ts new file mode 100644 index 00000000..a969e321 --- /dev/null +++ b/backend/src/api-keys/guards/api-key.guard.ts @@ -0,0 +1,29 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { ApiKeysService } from '../api-keys.service'; + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor(private readonly apiKeysService: ApiKeysService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context + .switchToHttp() + .getRequest(); + const header = request.headers['x-api-key']; + const rawKey = Array.isArray(header) ? header[0] : header; + + if (!rawKey) throw new UnauthorizedException('API key required'); + + const apiKey = await this.apiKeysService.validateKey(rawKey); + if (!apiKey) throw new UnauthorizedException('Invalid or inactive API key'); + + request.apiKeyUserId = apiKey.userId; + return true; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2947087e..1ac9524f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -40,6 +40,8 @@ import { LocationUpdatesModule } from './location-updates/location-updates.modul import { ETAModule } from './eta/eta.module'; import { BidExpiryModule } from './bid-expiry/bid-expiry.module'; import { HealthModule } from './health/health.module'; +import { QueuesModule } from './queues/queues.module'; +import { ApiKeysModule } from './api-keys/api-keys.module'; const shipmentCreateTracker = (context: ExecutionContext): string => { const request = context.switchToHttp().getRequest<{ @@ -130,6 +132,14 @@ const throttlerErrorMessage = (context: ExecutionContext): string => { OnboardingModule, RequestLoggerModule, HealthModule, + DocumentPipelineModule, + StellarEscrowModule, + ReputationCalculatorModule, + LocationUpdatesModule, + ETAModule, + BidExpiryModule, + QueuesModule, + ApiKeysModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/audit-log/audit-log.controller.spec.ts b/backend/src/audit-log/audit-log.controller.spec.ts index 93252946..507ea7e7 100644 --- a/backend/src/audit-log/audit-log.controller.spec.ts +++ b/backend/src/audit-log/audit-log.controller.spec.ts @@ -24,7 +24,10 @@ describe('AuditLogController', () => { }); it('should not have a DELETE endpoint', () => { - const prototype = Object.getPrototypeOf(controller); + const prototype = Object.getPrototypeOf(controller) as Record< + string, + unknown + >; const methodNames = Object.getOwnPropertyNames(prototype).filter( (name) => name !== 'constructor' && typeof prototype[name] === 'function', ); @@ -32,7 +35,7 @@ describe('AuditLogController', () => { for (const methodName of methodNames) { const httpMethod = Reflect.getMetadata( METHODS_METADATA, - prototype[methodName], + prototype[methodName] as object, ); expect(httpMethod).not.toBe(RequestMethod.DELETE); } diff --git a/backend/src/audit-log/audit-log.controller.ts b/backend/src/audit-log/audit-log.controller.ts index de2bd0c6..83dbf7e4 100644 --- a/backend/src/audit-log/audit-log.controller.ts +++ b/backend/src/audit-log/audit-log.controller.ts @@ -1,7 +1,13 @@ import { Controller, Get, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { AuditLogService } from './audit-log.service'; import { QueryAuditLogDto } from './dto/query-audit-log.dto'; +import { PaginatedAuditLogResponseDto } from './dto/audit-log-response.dto'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../auth/decorators/roles.decorator'; import { UserRole } from '../common/enums/role.enum'; @@ -18,6 +24,7 @@ export class AuditLogController { @ApiOperation({ summary: 'Get paginated admin audit logs (filterable by action)', }) + @ApiResponse({ status: 200, type: PaginatedAuditLogResponseDto }) findAll(@Query() query: QueryAuditLogDto) { return this.auditLogService.findAll(query); } diff --git a/backend/src/bid-expiry/bid-expiry.service.spec.ts b/backend/src/bid-expiry/bid-expiry.service.spec.ts index 76ba9345..33afcb77 100644 --- a/backend/src/bid-expiry/bid-expiry.service.spec.ts +++ b/backend/src/bid-expiry/bid-expiry.service.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConfigService } from '@nestjs/config'; import { BidExpiryService } from './bid-expiry.service'; -import { Bid, BidStatus } from '../bids/entities/bid.entity'; +import { Bid } from '../bids/entities/bid.entity'; describe('BidExpiryService', () => { let service: BidExpiryService; diff --git a/backend/src/queues/processors/email-send.processor.ts b/backend/src/queues/processors/email-send.processor.ts new file mode 100644 index 00000000..01fd1820 --- /dev/null +++ b/backend/src/queues/processors/email-send.processor.ts @@ -0,0 +1,24 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; + +@Processor('email-send') +export class EmailSendProcessor extends WorkerHost { + private readonly logger = new Logger(EmailSendProcessor.name); + + process(job: Job): Promise { + this.logger.log(`Processing email-send job ${job.id} (${job.name})`); + switch (job.name) { + case 'send-email': + this.handleSendEmail(job.data as Record); + break; + default: + this.logger.warn(`Unknown job name: ${job.name}`); + } + return Promise.resolve(); + } + + private handleSendEmail(data: Record): void { + this.logger.log(`Sending email to: ${String(data['to'])}`); + } +} diff --git a/backend/src/queues/processors/pdf-generate.processor.ts b/backend/src/queues/processors/pdf-generate.processor.ts new file mode 100644 index 00000000..8117116d --- /dev/null +++ b/backend/src/queues/processors/pdf-generate.processor.ts @@ -0,0 +1,26 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; + +@Processor('pdf-generate') +export class PdfGenerateProcessor extends WorkerHost { + private readonly logger = new Logger(PdfGenerateProcessor.name); + + process(job: Job): Promise { + this.logger.log(`Processing pdf-generate job ${job.id} (${job.name})`); + switch (job.name) { + case 'generate-pdf': + this.handleGeneratePdf(job.data as Record); + break; + default: + this.logger.warn(`Unknown job name: ${job.name}`); + } + return Promise.resolve(); + } + + private handleGeneratePdf(data: Record): void { + this.logger.log( + `Generating PDF for shipment: ${String(data['shipmentId'])}`, + ); + } +} diff --git a/backend/src/queues/processors/stellar-anchor.processor.ts b/backend/src/queues/processors/stellar-anchor.processor.ts new file mode 100644 index 00000000..e057a423 --- /dev/null +++ b/backend/src/queues/processors/stellar-anchor.processor.ts @@ -0,0 +1,24 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bullmq'; + +@Processor('stellar-anchor') +export class StellarAnchorProcessor extends WorkerHost { + private readonly logger = new Logger(StellarAnchorProcessor.name); + + process(job: Job): Promise { + this.logger.log(`Processing stellar-anchor job ${job.id} (${job.name})`); + switch (job.name) { + case 'anchor-payment': + this.handleAnchorPayment(job.data as Record); + break; + default: + this.logger.warn(`Unknown job name: ${job.name}`); + } + return Promise.resolve(); + } + + private handleAnchorPayment(data: Record): void { + this.logger.log(`Anchor payment job data: ${JSON.stringify(data)}`); + } +} diff --git a/backend/src/queues/queues.controller.ts b/backend/src/queues/queues.controller.ts new file mode 100644 index 00000000..bc171709 --- /dev/null +++ b/backend/src/queues/queues.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { QueuesService, QueueStats } from './queues.service'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../common/enums/role.enum'; + +@ApiTags('admin') +@ApiBearerAuth() +@Controller('admin/queues') +@UseGuards(RolesGuard) +@Roles(UserRole.ADMIN) +export class QueuesController { + constructor(private readonly queuesService: QueuesService) {} + + @Get('stats') + @ApiOperation({ summary: 'Get BullMQ queue statistics for all queues' }) + @ApiResponse({ status: 200, description: 'Queue stats retrieved' }) + async getStats(): Promise { + return this.queuesService.getStats(); + } +} diff --git a/backend/src/queues/queues.module.ts b/backend/src/queues/queues.module.ts new file mode 100644 index 00000000..7231d1eb --- /dev/null +++ b/backend/src/queues/queues.module.ts @@ -0,0 +1,41 @@ +import { Module } from '@nestjs/common'; +import { BullModule } from '@nestjs/bullmq'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { QueuesController } from './queues.controller'; +import { QueuesService } from './queues.service'; +import { StellarAnchorProcessor } from './processors/stellar-anchor.processor'; +import { EmailSendProcessor } from './processors/email-send.processor'; +import { PdfGenerateProcessor } from './processors/pdf-generate.processor'; +import { StuckShipmentScheduler } from './schedulers/stuck-shipment.scheduler'; +import { TempCleanupScheduler } from './schedulers/temp-cleanup.scheduler'; + +@Module({ + imports: [ + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + connection: { + url: + configService.get('REDIS_URL') ?? 'redis://localhost:6379', + }, + }), + }), + BullModule.registerQueue( + { name: 'stellar-anchor' }, + { name: 'email-send' }, + { name: 'pdf-generate' }, + ), + ], + controllers: [QueuesController], + providers: [ + QueuesService, + StellarAnchorProcessor, + EmailSendProcessor, + PdfGenerateProcessor, + StuckShipmentScheduler, + TempCleanupScheduler, + ], + exports: [BullModule], +}) +export class QueuesModule {} diff --git a/backend/src/queues/queues.service.ts b/backend/src/queues/queues.service.ts new file mode 100644 index 00000000..06c703dd --- /dev/null +++ b/backend/src/queues/queues.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +export interface QueueStats { + name: string; + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; +} + +@Injectable() +export class QueuesService { + constructor( + @InjectQueue('stellar-anchor') private readonly stellarQueue: Queue, + @InjectQueue('email-send') private readonly emailQueue: Queue, + @InjectQueue('pdf-generate') private readonly pdfQueue: Queue, + ) {} + + async getStats(): Promise { + const queues = [ + { name: 'stellar-anchor', queue: this.stellarQueue }, + { name: 'email-send', queue: this.emailQueue }, + { name: 'pdf-generate', queue: this.pdfQueue }, + ]; + + return Promise.all( + queues.map(async ({ name, queue }) => { + const [waiting, active, completed, failed, delayed] = await Promise.all( + [ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + queue.getDelayedCount(), + ], + ); + return { name, waiting, active, completed, failed, delayed }; + }), + ); + } +} diff --git a/backend/src/queues/schedulers/stuck-shipment.scheduler.ts b/backend/src/queues/schedulers/stuck-shipment.scheduler.ts new file mode 100644 index 00000000..7c533f0e --- /dev/null +++ b/backend/src/queues/schedulers/stuck-shipment.scheduler.ts @@ -0,0 +1,22 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +@Injectable() +export class StuckShipmentScheduler { + private readonly logger = new Logger(StuckShipmentScheduler.name); + + constructor( + @InjectQueue('stellar-anchor') private readonly stellarQueue: Queue, + ) {} + + @Cron('0 2 * * *') + async handleStuckShipments(): Promise { + this.logger.log('Running stuck shipment check at 02:00'); + await this.stellarQueue.add('anchor-payment', { + type: 'stuck-check', + triggeredAt: new Date().toISOString(), + }); + } +} diff --git a/backend/src/queues/schedulers/temp-cleanup.scheduler.ts b/backend/src/queues/schedulers/temp-cleanup.scheduler.ts new file mode 100644 index 00000000..19343405 --- /dev/null +++ b/backend/src/queues/schedulers/temp-cleanup.scheduler.ts @@ -0,0 +1,20 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; + +@Injectable() +export class TempCleanupScheduler { + private readonly logger = new Logger(TempCleanupScheduler.name); + + constructor(@InjectQueue('pdf-generate') private readonly pdfQueue: Queue) {} + + @Cron('0 3 * * *') + async handleTempCleanup(): Promise { + this.logger.log('Running temp file cleanup at 03:00'); + await this.pdfQueue.add('generate-pdf', { + type: 'cleanup', + triggeredAt: new Date().toISOString(), + }); + } +} diff --git a/backend/src/webhooks/webhooks.controller.ts b/backend/src/webhooks/webhooks.controller.ts index e1cb5889..db0dc23e 100644 --- a/backend/src/webhooks/webhooks.controller.ts +++ b/backend/src/webhooks/webhooks.controller.ts @@ -21,6 +21,7 @@ import { RolesGuard } from '../auth/guards/roles.guard'; import { UserRole } from '../common/enums/role.enum'; import { User } from '../users/entities/user.entity'; import { CreateWebhookDto } from './dto/create-webhook.dto'; +import { WebhookResponseDto } from './dto/webhook-response.dto'; import { WebhooksService } from './webhooks.service'; @ApiTags('webhooks') @@ -44,6 +45,7 @@ export class WebhooksController { @UseGuards(RolesGuard) @Roles(UserRole.SHIPPER, UserRole.ADMIN) @ApiOperation({ summary: 'List my registered webhooks' }) + @ApiResponse({ status: 200, type: [WebhookResponseDto] }) findAll(@CurrentUser() user: User) { return this.webhooksService.findAllForUser(user.id); } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..381e1775 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,69 @@ +version: "3.9" + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${DATABASE_USERNAME:-freightflow} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD} + POSTGRES_DB: ${DATABASE_NAME:-freightflow} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USERNAME:-freightflow}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --save 60 1 --loglevel warning + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + environment: + NODE_ENV: production + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + DATABASE_USERNAME: ${DATABASE_USERNAME:-freightflow} + DATABASE_PASSWORD: ${DATABASE_PASSWORD} + DATABASE_NAME: ${DATABASE_NAME:-freightflow} + REDIS_URL: redis://redis:6379 + JWT_SECRET: ${JWT_SECRET} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-15m} + ports: + - "3000:3000" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + restart: unless-stopped + environment: + NODE_ENV: production + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3000} + ports: + - "3001:3001" + depends_on: + - backend + +volumes: + postgres_data: + redis_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..09f96db8 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-alpine AS base +WORKDIR /app + +FROM base AS deps +COPY package*.json ./ +RUN npm ci --only=production + +FROM base AS build +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM base AS runtime +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs +COPY --from=build /app/public ./public +COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3001 +CMD ["node", "server.js"] diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa308..dc9dd493 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,41 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self'", + "connect-src 'self' https:", + "frame-ancestors 'none'", + ].join("; "), + }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, +]; + const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ]; + }, }; export default nextConfig; From be29702fef9b8ab45037016d7cb46ebea77c4609 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 27 Jun 2026 18:29:04 +0100 Subject: [PATCH 3/6] fix: resolve merge conflicts and fix all lint errors after rebase on upstream/main --- .../carrier-search/carrier-search.service.ts | 83 +++++- .../carrier-search/dto/carrier-search.dto.ts | 10 +- .../tests/carrier-search.service.spec.ts | 22 +- .../currency-conversion/src/app.module.ts | 3 +- .../src/currency-conversion.controller.ts | 7 +- .../src/currency-conversion.module.ts | 3 +- .../src/currency-conversion.service.ts | 7 +- .../package/currency-conversion/src/main.ts | 3 +- .../test/currency-conversion.service.spec.ts | 17 +- .../env-validation/env-validation.module.ts | 3 +- .../export/shipment-export.controller.ts | 14 +- .../export/shipment-export.service.spec.ts | 18 +- .../package/export/shipment-export.service.ts | 16 +- .../dto/update-preferences.dto.ts | 5 +- .../notification-preference.entity.ts | 8 +- .../notification-preferences.controller.ts | 9 +- .../notification-preferences.service.spec.ts | 41 ++- .../notification-preferences.service.ts | 13 +- .../package/tests/auth/auth.service.spec.ts | 244 ++++++++++++------ .../admin/admin-user-management.service.ts | 52 +++- backend/src/analytics/analytics.service.ts | 33 ++- backend/src/auth/two-factor.service.ts | 14 +- backend/src/carriers/fleet.service.ts | 58 ++++- backend/src/dashboard/dashboard.service.ts | 19 +- backend/src/disputes/evidence.service.ts | 47 +++- backend/src/health/health.controller.spec.ts | 20 +- backend/src/health/health.controller.ts | 4 +- backend/src/health/health.module.ts | 6 +- .../cloudinary.health.indicator.spec.ts | 8 +- .../indicators/cloudinary.health.indicator.ts | 11 +- .../health/indicators/db.health.indicator.ts | 4 +- .../indicators/smtp.health.indicator.spec.ts | 2 +- .../indicators/smtp.health.indicator.ts | 12 +- .../src/location-updates/tracking.service.ts | 50 +++- .../organizations/organizations.service.ts | 61 +++-- backend/src/payments/payments.service.ts | 60 ++++- backend/src/quotes/quotes.service.ts | 47 +++- backend/src/reports/reports.service.ts | 36 ++- backend/src/reviews/review-stats.service.ts | 16 +- backend/src/shipments/shipments.module.ts | 5 +- .../stellar-anchor/stellar-anchor.service.ts | 41 ++- .../stellar-escrow/escrow-payment.service.ts | 47 +++- backend/src/wallets/wallets.service.ts | 34 ++- 43 files changed, 903 insertions(+), 310 deletions(-) diff --git a/backend/package/carrier-search/carrier-search.service.ts b/backend/package/carrier-search/carrier-search.service.ts index 6f2043a0..46e3bcdc 100644 --- a/backend/package/carrier-search/carrier-search.service.ts +++ b/backend/package/carrier-search/carrier-search.service.ts @@ -13,14 +13,81 @@ export interface CarrierResult { } const MOCK_CARRIERS: CarrierResult[] = [ - { id: 'c1', name: 'SwiftHaul Logistics', rating: 4.8, completedShipments: 1820, vehicleTypes: ['truck', 'van'], available: true, routes: [{ origin: 'Lagos', destination: 'Abuja' }, { origin: 'Kano', destination: 'Lagos' }] }, - { id: 'c2', name: 'Eagle Freight Co.', rating: 4.2, completedShipments: 540, vehicleTypes: ['truck'], available: true, routes: [{ origin: 'Ibadan', destination: 'Kano' }] }, - { id: 'c3', name: 'Meridian Cargo', rating: 3.9, completedShipments: 300, vehicleTypes: ['van', 'motorcycle'], available: false, routes: [{ origin: 'Lagos', destination: 'Port Harcourt' }] }, - { id: 'c4', name: 'Atlas Express', rating: 4.5, completedShipments: 980, vehicleTypes: ['truck', 'flatbed'], available: true, routes: [{ origin: 'Abuja', destination: 'Enugu' }] }, - { id: 'c5', name: 'Horizon Shipping', rating: 4.6, completedShipments: 1200, vehicleTypes: ['truck'], available: true, routes: [{ origin: 'Kano', destination: 'Abuja' }] }, - { id: 'c6', name: 'Apex Freight', rating: 2.8, completedShipments: 90, vehicleTypes: ['van'], available: true, routes: [{ origin: 'Benin City', destination: 'Lagos' }] }, - { id: 'c7', name: 'Delta Carriers', rating: 4.1, completedShipments: 430, vehicleTypes: ['refrigerated truck'], available: false, routes: [{ origin: 'Lagos', destination: 'Kano' }] }, - { id: 'c8', name: 'Coastal Haul', rating: 3.5, completedShipments: 210, vehicleTypes: ['flatbed', 'truck'], available: true, routes: [{ origin: 'Calabar', destination: 'Abuja' }] }, + { + id: 'c1', + name: 'SwiftHaul Logistics', + rating: 4.8, + completedShipments: 1820, + vehicleTypes: ['truck', 'van'], + available: true, + routes: [ + { origin: 'Lagos', destination: 'Abuja' }, + { origin: 'Kano', destination: 'Lagos' }, + ], + }, + { + id: 'c2', + name: 'Eagle Freight Co.', + rating: 4.2, + completedShipments: 540, + vehicleTypes: ['truck'], + available: true, + routes: [{ origin: 'Ibadan', destination: 'Kano' }], + }, + { + id: 'c3', + name: 'Meridian Cargo', + rating: 3.9, + completedShipments: 300, + vehicleTypes: ['van', 'motorcycle'], + available: false, + routes: [{ origin: 'Lagos', destination: 'Port Harcourt' }], + }, + { + id: 'c4', + name: 'Atlas Express', + rating: 4.5, + completedShipments: 980, + vehicleTypes: ['truck', 'flatbed'], + available: true, + routes: [{ origin: 'Abuja', destination: 'Enugu' }], + }, + { + id: 'c5', + name: 'Horizon Shipping', + rating: 4.6, + completedShipments: 1200, + vehicleTypes: ['truck'], + available: true, + routes: [{ origin: 'Kano', destination: 'Abuja' }], + }, + { + id: 'c6', + name: 'Apex Freight', + rating: 2.8, + completedShipments: 90, + vehicleTypes: ['van'], + available: true, + routes: [{ origin: 'Benin City', destination: 'Lagos' }], + }, + { + id: 'c7', + name: 'Delta Carriers', + rating: 4.1, + completedShipments: 430, + vehicleTypes: ['refrigerated truck'], + available: false, + routes: [{ origin: 'Lagos', destination: 'Kano' }], + }, + { + id: 'c8', + name: 'Coastal Haul', + rating: 3.5, + completedShipments: 210, + vehicleTypes: ['flatbed', 'truck'], + available: true, + routes: [{ origin: 'Calabar', destination: 'Abuja' }], + }, ]; @Injectable() diff --git a/backend/package/carrier-search/dto/carrier-search.dto.ts b/backend/package/carrier-search/dto/carrier-search.dto.ts index dc20244e..40e80160 100644 --- a/backend/package/carrier-search/dto/carrier-search.dto.ts +++ b/backend/package/carrier-search/dto/carrier-search.dto.ts @@ -1,5 +1,13 @@ import { Type } from 'class-transformer'; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsBoolean, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; import { PaginationDto } from '../../../src/package/pagination/dto/pagination.dto'; export class CarrierSearchDto extends PaginationDto { diff --git a/backend/package/carrier-search/tests/carrier-search.service.spec.ts b/backend/package/carrier-search/tests/carrier-search.service.spec.ts index 486a5c85..4c05ca1e 100644 --- a/backend/package/carrier-search/tests/carrier-search.service.spec.ts +++ b/backend/package/carrier-search/tests/carrier-search.service.spec.ts @@ -9,7 +9,11 @@ describe('CarrierSearchService', () => { }); function dto(overrides: Partial = {}): CarrierSearchDto { - return Object.assign(new CarrierSearchDto(), { page: 1, limit: 20 }, overrides); + return Object.assign( + new CarrierSearchDto(), + { page: 1, limit: 20 }, + overrides, + ); } it('returns all carriers with no filters', () => { @@ -43,26 +47,34 @@ describe('CarrierSearchService', () => { const result = service.search(dto({ origin: 'lagos' })); expect(result.data.length).toBeGreaterThan(0); result.data.forEach((c) => - expect(c.routes.some((r) => r.origin.toLowerCase().includes('lagos'))).toBe(true), + expect( + c.routes.some((r) => r.origin.toLowerCase().includes('lagos')), + ).toBe(true), ); }); it('filters by vehicleType', () => { const result = service.search(dto({ vehicleType: 'van' })); - expect(result.data.every((c) => c.vehicleTypes.some((v) => v.includes('van')))).toBe(true); + expect( + result.data.every((c) => c.vehicleTypes.some((v) => v.includes('van'))), + ).toBe(true); }); it('sorts by completedShipments descending', () => { const result = service.search(dto({ sortBy: 'completedShipments' })); for (let i = 1; i < result.data.length; i++) { - expect(result.data[i - 1].completedShipments).toBeGreaterThanOrEqual(result.data[i].completedShipments); + expect(result.data[i - 1].completedShipments).toBeGreaterThanOrEqual( + result.data[i].completedShipments, + ); } }); it('default sort is rating descending', () => { const result = service.search(dto()); for (let i = 1; i < result.data.length; i++) { - expect(result.data[i - 1].rating).toBeGreaterThanOrEqual(result.data[i].rating); + expect(result.data[i - 1].rating).toBeGreaterThanOrEqual( + result.data[i].rating, + ); } }); diff --git a/backend/package/currency-conversion/src/app.module.ts b/backend/package/currency-conversion/src/app.module.ts index 9d79dfee..141f49bc 100644 --- a/backend/package/currency-conversion/src/app.module.ts +++ b/backend/package/currency-conversion/src/app.module.ts @@ -1,8 +1,7 @@ - import { Module } from '@nestjs/common'; import { CurrencyConversionModule } from './currency-conversion.module'; @Module({ imports: [CurrencyConversionModule], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/backend/package/currency-conversion/src/currency-conversion.controller.ts b/backend/package/currency-conversion/src/currency-conversion.controller.ts index d40c624b..56ba2a9f 100644 --- a/backend/package/currency-conversion/src/currency-conversion.controller.ts +++ b/backend/package/currency-conversion/src/currency-conversion.controller.ts @@ -1,13 +1,14 @@ - import { Controller, Get } from '@nestjs/common'; import { CurrencyConversionService } from './currency-conversion.service'; @Controller('api/currencies') export class CurrencyConversionController { - constructor(private readonly currencyConversionService: CurrencyConversionService) {} + constructor( + private readonly currencyConversionService: CurrencyConversionService, + ) {} @Get() getRates() { return this.currencyConversionService.getRates(); } -} \ No newline at end of file +} diff --git a/backend/package/currency-conversion/src/currency-conversion.module.ts b/backend/package/currency-conversion/src/currency-conversion.module.ts index b0e7a48e..4f39e275 100644 --- a/backend/package/currency-conversion/src/currency-conversion.module.ts +++ b/backend/package/currency-conversion/src/currency-conversion.module.ts @@ -1,4 +1,3 @@ - import { Module } from '@nestjs/common'; import { CurrencyConversionController } from './currency-conversion.controller'; import { CurrencyConversionService } from './currency-conversion.service'; @@ -7,4 +6,4 @@ import { CurrencyConversionService } from './currency-conversion.service'; controllers: [CurrencyConversionController], providers: [CurrencyConversionService], }) -export class CurrencyConversionModule {} \ No newline at end of file +export class CurrencyConversionModule {} diff --git a/backend/package/currency-conversion/src/currency-conversion.service.ts b/backend/package/currency-conversion/src/currency-conversion.service.ts index 08b0f9ea..33a33483 100644 --- a/backend/package/currency-conversion/src/currency-conversion.service.ts +++ b/backend/package/currency-conversion/src/currency-conversion.service.ts @@ -1,4 +1,3 @@ - import { Injectable, BadRequestException } from '@nestjs/common'; import * as fs from 'fs'; import * as path from 'path'; @@ -9,7 +8,9 @@ export class CurrencyConversionService { constructor() { const ratesPath = path.join(__dirname, '..', 'rates.json'); - this.exchangeRates = JSON.parse(fs.readFileSync(ratesPath, 'utf8')); + this.exchangeRates = JSON.parse(fs.readFileSync(ratesPath, 'utf8')) as { + [key: string]: number; + }; } convert(amount: number, fromCurrency: string, toCurrency: string): number { @@ -33,4 +34,4 @@ export class CurrencyConversionService { getRates() { return this.exchangeRates; } -} \ No newline at end of file +} diff --git a/backend/package/currency-conversion/src/main.ts b/backend/package/currency-conversion/src/main.ts index 83c29002..13cad38c 100644 --- a/backend/package/currency-conversion/src/main.ts +++ b/backend/package/currency-conversion/src/main.ts @@ -1,4 +1,3 @@ - import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; @@ -6,4 +5,4 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); } -bootstrap(); \ No newline at end of file +bootstrap(); diff --git a/backend/package/currency-conversion/test/currency-conversion.service.spec.ts b/backend/package/currency-conversion/test/currency-conversion.service.spec.ts index d079e01f..9f69e57f 100644 --- a/backend/package/currency-conversion/test/currency-conversion.service.spec.ts +++ b/backend/package/currency-conversion/test/currency-conversion.service.spec.ts @@ -1,6 +1,5 @@ - import { Test, TestingModule } from '@nestjs/testing'; -import { CurrencyConversionService } from './currency-conversion.service'; +import { CurrencyConversionService } from '../src/currency-conversion.service'; import { BadRequestException } from '@nestjs/common'; describe('CurrencyConversionService', () => { @@ -41,12 +40,12 @@ describe('CurrencyConversionService', () => { it('should return the correct rates', () => { const rates = service.getRates(); expect(rates).toEqual({ - "USD": 1, - "EUR": 0.93, - "GBP": 0.79, - "NGN": 1481.58, - "KES": 128.5, - "ZAR": 18.24 + USD: 1, + EUR: 0.93, + GBP: 0.79, + NGN: 1481.58, + KES: 128.5, + ZAR: 18.24, }); }); -}); \ No newline at end of file +}); diff --git a/backend/package/env-validation/env-validation.module.ts b/backend/package/env-validation/env-validation.module.ts index e3e6b1e6..bdff80d6 100644 --- a/backend/package/env-validation/env-validation.module.ts +++ b/backend/package/env-validation/env-validation.module.ts @@ -1,4 +1,3 @@ - import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi'; @@ -36,4 +35,4 @@ import * as Joi from 'joi'; }), ], }) -export class EnvValidationModule {} \ No newline at end of file +export class EnvValidationModule {} diff --git a/backend/package/export/shipment-export.controller.ts b/backend/package/export/shipment-export.controller.ts index 9c5845b3..0efb9864 100644 --- a/backend/package/export/shipment-export.controller.ts +++ b/backend/package/export/shipment-export.controller.ts @@ -1,7 +1,17 @@ -import { Controller, Get, Query, Res, Request, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Query, + Res, + Request, + UseGuards, +} from '@nestjs/common'; import { Response } from 'express'; import { AuthGuard } from '@nestjs/passport'; -import { ShipmentExportService, ShipmentExportQuery } from './shipment-export.service'; +import { + ShipmentExportService, + ShipmentExportQuery, +} from './shipment-export.service'; interface AuthRequest { user: { id: string; role?: string }; diff --git a/backend/package/export/shipment-export.service.spec.ts b/backend/package/export/shipment-export.service.spec.ts index 09163ffe..8f074984 100644 --- a/backend/package/export/shipment-export.service.spec.ts +++ b/backend/package/export/shipment-export.service.spec.ts @@ -35,7 +35,9 @@ function mockResponse() { return { res: { setHeader: jest.fn(), - write: jest.fn((chunk: string) => { chunks.push(chunk); }), + write: jest.fn((chunk: string) => { + chunks.push(chunk); + }), end: jest.fn(), } as unknown as Response, getBody: () => chunks.join(''), @@ -69,7 +71,10 @@ describe('ShipmentExportService', () => { await service.streamCsv(res, 'user-1', false, {}); expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv'); - expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename=shipments.csv'); + expect(res.setHeader).toHaveBeenCalledWith( + 'Content-Disposition', + 'attachment; filename=shipments.csv', + ); const body = getBody(); expect(body).toContain('Tracking Number,Origin,Destination,Status'); @@ -98,7 +103,9 @@ describe('ShipmentExportService', () => { await service.streamCsv(res, 'user-1', false, {}); expect(repo.find).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.objectContaining({ shipperId: 'user-1' }) }), + expect.objectContaining({ + where: expect.objectContaining({ shipperId: 'user-1' }), + }), ); }); @@ -108,7 +115,10 @@ describe('ShipmentExportService', () => { await service.streamCsv(res, 'admin-1', true, {}); - const callArg = repo.find.mock.calls[0][0] as { where: Record }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const callArg = repo.find.mock.calls[0][0] as { + where: Record; + }; expect(callArg.where).not.toHaveProperty('shipperId'); }); }); diff --git a/backend/package/export/shipment-export.service.ts b/backend/package/export/shipment-export.service.ts index d20f1edf..931cacf4 100644 --- a/backend/package/export/shipment-export.service.ts +++ b/backend/package/export/shipment-export.service.ts @@ -1,7 +1,14 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, FindOptionsWhere, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { + Repository, + FindOptionsWhere, + Between, + MoreThanOrEqual, + LessThanOrEqual, +} from 'typeorm'; import { Response } from 'express'; +import { Shipment } from '../../src/shipments/entities/shipment.entity'; export interface ShipmentExportRow { trackingNumber: string; @@ -74,7 +81,7 @@ function rowToCsv(s: ShipmentLike): string { @Injectable() export class ShipmentExportService { constructor( - @InjectRepository('Shipment') + @InjectRepository(Shipment) private readonly shipmentRepo: Repository, ) {} @@ -90,7 +97,10 @@ export class ShipmentExportService { if (query.status) where['status'] = query.status; if (query.startDate && query.endDate) { - where['createdAt'] = Between(new Date(query.startDate), new Date(query.endDate)); + where['createdAt'] = Between( + new Date(query.startDate), + new Date(query.endDate), + ); } else if (query.startDate) { where['createdAt'] = MoreThanOrEqual(new Date(query.startDate)); } else if (query.endDate) { diff --git a/backend/package/notification-preferences/dto/update-preferences.dto.ts b/backend/package/notification-preferences/dto/update-preferences.dto.ts index 186e119e..b62d001c 100644 --- a/backend/package/notification-preferences/dto/update-preferences.dto.ts +++ b/backend/package/notification-preferences/dto/update-preferences.dto.ts @@ -1,6 +1,9 @@ import { IsArray, IsBoolean, IsEnum, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { NotificationChannel, NotificationEventType } from '../entities/notification-preference.entity'; +import { + NotificationChannel, + NotificationEventType, +} from '../entities/notification-preference.entity'; export class PreferenceItemDto { @IsEnum(NotificationEventType) diff --git a/backend/package/notification-preferences/entities/notification-preference.entity.ts b/backend/package/notification-preferences/entities/notification-preference.entity.ts index 711007c5..f642b6c1 100644 --- a/backend/package/notification-preferences/entities/notification-preference.entity.ts +++ b/backend/package/notification-preferences/entities/notification-preference.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; export enum NotificationEventType { BID_PLACED = 'BID_PLACED', diff --git a/backend/package/notification-preferences/notification-preferences.controller.ts b/backend/package/notification-preferences/notification-preferences.controller.ts index 47cd09b6..2da51f51 100644 --- a/backend/package/notification-preferences/notification-preferences.controller.ts +++ b/backend/package/notification-preferences/notification-preferences.controller.ts @@ -1,4 +1,11 @@ -import { Body, Controller, Get, Patch, Request, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Patch, + Request, + UseGuards, +} from '@nestjs/common'; import { NotificationPreferencesService } from './notification-preferences.service'; import { UpdatePreferencesDto } from './dto/update-preferences.dto'; import { AuthGuard } from '@nestjs/passport'; diff --git a/backend/package/notification-preferences/notification-preferences.service.spec.ts b/backend/package/notification-preferences/notification-preferences.service.spec.ts index 11f85a3e..4b57eff8 100644 --- a/backend/package/notification-preferences/notification-preferences.service.spec.ts +++ b/backend/package/notification-preferences/notification-preferences.service.spec.ts @@ -13,7 +13,13 @@ const CHANNELS = Object.values(NotificationChannel); function buildDefaults(userId = 'user-1'): NotificationPreference[] { return EVENT_TYPES.flatMap((eventType) => CHANNELS.map((channel) => - Object.assign(new NotificationPreference(), { id: `${eventType}-${channel}`, userId, eventType, channel, enabled: true }), + Object.assign(new NotificationPreference(), { + id: `${eventType}-${channel}`, + userId, + eventType, + channel, + enabled: true, + }), ), ); } @@ -21,8 +27,12 @@ function buildDefaults(userId = 'user-1'): NotificationPreference[] { const mockRepo = () => ({ find: jest.fn(), findOne: jest.fn(), - create: jest.fn((dto: Partial) => Object.assign(new NotificationPreference(), dto)), - save: jest.fn(async (entity: NotificationPreference | NotificationPreference[]) => entity), + create: jest.fn((dto: Partial) => + Object.assign(new NotificationPreference(), dto), + ), + save: jest.fn((entity: NotificationPreference | NotificationPreference[]) => + Promise.resolve(entity), + ), }); describe('NotificationPreferencesService', () => { @@ -33,7 +43,10 @@ describe('NotificationPreferencesService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ NotificationPreferencesService, - { provide: getRepositoryToken(NotificationPreference), useFactory: mockRepo }, + { + provide: getRepositoryToken(NotificationPreference), + useFactory: mockRepo, + }, ], }).compile(); @@ -52,7 +65,7 @@ describe('NotificationPreferencesService', () => { it('creates defaults (all enabled) when no preferences exist', async () => { repo.find - .mockResolvedValueOnce([]) // first call: empty → triggers creation + .mockResolvedValueOnce([]) // first call: empty → triggers creation .mockResolvedValueOnce(buildDefaults()); // second call: after save repo.save.mockResolvedValue(buildDefaults()); @@ -71,10 +84,18 @@ describe('NotificationPreferencesService', () => { repo.save.mockResolvedValue({ ...target, enabled: false }); await service.updatePreferences('user-1', { - preferences: [{ eventType: target.eventType, channel: target.channel, enabled: false }], + preferences: [ + { + eventType: target.eventType, + channel: target.channel, + enabled: false, + }, + ], }); - expect(repo.save).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); }); it('creates a new preference record when it does not exist yet', async () => { @@ -83,7 +104,11 @@ describe('NotificationPreferencesService', () => { await service.updatePreferences('user-1', { preferences: [ - { eventType: NotificationEventType.BID_PLACED, channel: NotificationChannel.PUSH, enabled: false }, + { + eventType: NotificationEventType.BID_PLACED, + channel: NotificationChannel.PUSH, + enabled: false, + }, ], }); diff --git a/backend/package/notification-preferences/notification-preferences.service.ts b/backend/package/notification-preferences/notification-preferences.service.ts index 3448acfa..3490af31 100644 --- a/backend/package/notification-preferences/notification-preferences.service.ts +++ b/backend/package/notification-preferences/notification-preferences.service.ts @@ -21,7 +21,10 @@ export class NotificationPreferencesService { return this.createDefaults(userId); } - async updatePreferences(userId: string, dto: UpdatePreferencesDto): Promise { + async updatePreferences( + userId: string, + dto: UpdatePreferencesDto, + ): Promise { for (const item of dto.preferences) { const pref = await this.repo.findOne({ where: { userId, eventType: item.eventType, channel: item.channel }, @@ -36,11 +39,15 @@ export class NotificationPreferencesService { return this.getPreferences(userId); } - private async createDefaults(userId: string): Promise { + private async createDefaults( + userId: string, + ): Promise { const defaults: NotificationPreference[] = []; for (const eventType of Object.values(NotificationEventType)) { for (const channel of Object.values(NotificationChannel)) { - defaults.push(this.repo.create({ userId, eventType, channel, enabled: true })); + defaults.push( + this.repo.create({ userId, eventType, channel, enabled: true }), + ); } } return this.repo.save(defaults); diff --git a/backend/package/tests/auth/auth.service.spec.ts b/backend/package/tests/auth/auth.service.spec.ts index 591a5799..6b8d67fb 100644 --- a/backend/package/tests/auth/auth.service.spec.ts +++ b/backend/package/tests/auth/auth.service.spec.ts @@ -1,21 +1,25 @@ - import { Test, TestingModule } from '@nestjs/testing'; -import { AuthService } from '../../src/auth/auth.service'; -import { UsersService } from '../../src/users/users.service'; +import { AuthService } from '../../../src/auth/auth.service'; +import { UsersService } from '../../../src/users/users.service'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; -import { MailService } from '../../src/mailer/mail.service'; +import { MailService } from '../../../src/mailer/mail.service'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { User } from '../../src/users/entities/user.entity'; +import { User } from '../../../src/users/entities/user.entity'; import { Repository } from 'typeorm'; -import { BadRequestException, ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { + BadRequestException, + ConflictException, + UnauthorizedException, +} from '@nestjs/common'; describe('AuthService', () => { let authService: AuthService; - let usersService: UsersService; - let jwtService: JwtService; - let mailService: MailService; - let userRepository: Repository; + let _usersService: UsersService; + let _jwtService: JwtService; + let _mailService: MailService; + let _userRepository: Repository; const mockUsersService = { create: jest.fn(), @@ -59,10 +63,10 @@ describe('AuthService', () => { }).compile(); authService = module.get(AuthService); - usersService = module.get(UsersService); - jwtService = module.get(JwtService); - mailService = module.get(MailService); - userRepository = module.get>(getRepositoryToken(User)); + _usersService = module.get(UsersService); + _jwtService = module.get(JwtService); + _mailService = module.get(MailService); + _userRepository = module.get>(getRepositoryToken(User)); }); it('should be defined', () => { @@ -71,9 +75,23 @@ describe('AuthService', () => { describe('register', () => { it('should register a new user and return tokens', async () => { - const registerDto = { email: 'test@test.com', password: 'password', firstName: 'Test', lastName: 'User', role: 'user' }; - const user = { id: '1', ...registerDto, passwordHash: 'hashedPassword', refreshToken: null, isActive: true, verificationToken: '', verificationTokenExpiry: new Date() }; - + const registerDto = { + email: 'test@test.com', + password: 'password', + firstName: 'Test', + lastName: 'User', + role: 'user', + }; + const user = { + id: '1', + ...registerDto, + passwordHash: 'hashedPassword', + refreshToken: null, + isActive: true, + verificationToken: '', + verificationTokenExpiry: new Date(), + }; + mockUsersService.create.mockResolvedValue(user); mockJwtService.signAsync.mockResolvedValue('test-token'); @@ -81,85 +99,149 @@ describe('AuthService', () => { expect(mockUsersService.create).toHaveBeenCalledWith(registerDto); expect(mockMailService.send).toHaveBeenCalled(); - expect(mockUsersService.updateRefreshToken).toHaveBeenCalledWith(user.id, 'test-token'); + expect(mockUsersService.updateRefreshToken).toHaveBeenCalledWith( + user.id, + 'test-token', + ); expect(result).toHaveProperty('accessToken', 'test-token'); expect(result).toHaveProperty('refreshToken', 'test-token'); }); it('should throw ConflictException if email is already taken', async () => { - const registerDto = { email: 'test@test.com', password: 'password', firstName: 'Test', lastName: 'User', role: 'user' }; - mockUsersService.create.mockRejectedValue(new ConflictException('Email already exists')); - - await expect(authService.register(registerDto)).rejects.toThrow(ConflictException); - }); - - it('should throw BadRequestException for weak passwords', async () => { - const registerDto = { email: 'test@test.com', password: '123', firstName: 'Test', lastName: 'User', role: 'user' }; - mockUsersService.create.mockRejectedValue(new BadRequestException('Password is too weak')); - - await expect(authService.register(registerDto)).rejects.toThrow(BadRequestException); - }); + const registerDto = { + email: 'test@test.com', + password: 'password', + firstName: 'Test', + lastName: 'User', + role: 'user', + }; + mockUsersService.create.mockRejectedValue( + new ConflictException('Email already exists'), + ); + + await expect(authService.register(registerDto)).rejects.toThrow( + ConflictException, + ); + }); + + it('should throw BadRequestException for weak passwords', async () => { + const registerDto = { + email: 'test@test.com', + password: '123', + firstName: 'Test', + lastName: 'User', + role: 'user', + }; + mockUsersService.create.mockRejectedValue( + new BadRequestException('Password is too weak'), + ); + + await expect(authService.register(registerDto)).rejects.toThrow( + BadRequestException, + ); + }); }); describe('login', () => { it('should login a user and return tokens', async () => { - const user = { id: '1', email: 'test@test.com', passwordHash: 'hashedPassword', refreshToken: null, isActive: true, role: 'user' }; - mockJwtService.signAsync.mockResolvedValue('test-token'); - - const result = await authService.login(user as any); - - expect(mockUsersService.updateRefreshToken).toHaveBeenCalledWith(user.id, 'test-token'); - expect(result).toHaveProperty('accessToken', 'test-token'); - expect(result).toHaveProperty('refreshToken', 'test-token'); - }); - - it('should throw UnauthorizedException for wrong password', async () => { - const user = { id: '1', email: 'test@test.com', passwordHash: 'hashedPassword', refreshToken: null, isActive: true, role: 'user' }; - mockUsersService.verifyPassword.mockResolvedValue(false); - - await expect(authService.validateUser('test@test.com', 'wrongpassword')).resolves.toBeNull(); - }); - - it('should throw ForbiddenException for unverified email', async () => { - const user = { id: '1', email: 'test@test.com', passwordHash: 'hashedPassword', refreshToken: null, isActive: false, role: 'user' }; - - await expect(authService.login(user as any)).rejects.toThrow(UnauthorizedException); - }); + const user = { + id: '1', + email: 'test@test.com', + passwordHash: 'hashedPassword', + refreshToken: null, + isActive: true, + role: 'user', + }; + mockJwtService.signAsync.mockResolvedValue('test-token'); + + const result = await authService.login(user as any); + + expect(mockUsersService.updateRefreshToken).toHaveBeenCalledWith( + user.id, + 'test-token', + ); + expect(result).toHaveProperty('accessToken', 'test-token'); + expect(result).toHaveProperty('refreshToken', 'test-token'); + }); + + it('should throw UnauthorizedException for wrong password', async () => { + mockUsersService.verifyPassword.mockResolvedValue(false); + + await expect( + authService.validateUser('test@test.com', 'wrongpassword'), + ).resolves.toBeNull(); + }); + + it('should throw ForbiddenException for unverified email', async () => { + const user = { + id: '1', + email: 'test@test.com', + passwordHash: 'hashedPassword', + refreshToken: null, + isActive: false, + role: 'user', + }; + + await expect(authService.login(user as any)).rejects.toThrow( + UnauthorizedException, + ); + }); }); describe('refreshToken', () => { it('should refresh tokens successfully', async () => { - const user = { id: '1', email: 'test@test.com', passwordHash: 'hashedPassword', refreshToken: 'hashed-refresh-token', isActive: true, role: 'user' }; - mockUsersService.findOne.mockResolvedValue(user); - mockUsersService.findByEmail.mockResolvedValue(user as any); - jest.spyOn(require('bcrypt'), 'compare').mockResolvedValue(true); - mockJwtService.signAsync.mockResolvedValue('new-test-token'); - - const result = await authService.refresh('1', 'raw-refresh-token'); - - expect(result).toHaveProperty('accessToken', 'new-test-token'); - expect(result).toHaveProperty('refreshToken', 'new-test-token'); - }); - - it('should throw UnauthorizedException for expired token', async () => { - mockUsersService.findOne.mockResolvedValue(null); - await expect(authService.refresh('1', 'raw-refresh-token')).rejects.toThrow(UnauthorizedException); - }); - - it('should throw UnauthorizedException for tampered token', async () => { - const user = { id: '1', email: 'test@test.com', passwordHash: 'hashedPassword', refreshToken: 'hashed-refresh-token', isActive: true, role: 'user' }; - mockUsersService.findOne.mockResolvedValue(user); - mockUsersService.findByEmail.mockResolvedValue(user as any); - jest.spyOn(require('bcrypt'), 'compare').mockResolvedValue(false); - - await expect(authService.refresh('1', 'raw-refresh-token')).rejects.toThrow(UnauthorizedException); - }); + const user = { + id: '1', + email: 'test@test.com', + passwordHash: 'hashedPassword', + refreshToken: 'hashed-refresh-token', + isActive: true, + role: 'user', + }; + mockUsersService.findOne.mockResolvedValue(user); + mockUsersService.findByEmail.mockResolvedValue(user as any); + jest.spyOn(bcrypt, 'compare').mockResolvedValue(true); + mockJwtService.signAsync.mockResolvedValue('new-test-token'); + + const result = await authService.refresh('1', 'raw-refresh-token'); + + expect(result).toHaveProperty('accessToken', 'new-test-token'); + expect(result).toHaveProperty('refreshToken', 'new-test-token'); + }); + + it('should throw UnauthorizedException for expired token', async () => { + mockUsersService.findOne.mockResolvedValue(null); + await expect( + authService.refresh('1', 'raw-refresh-token'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException for tampered token', async () => { + const user = { + id: '1', + email: 'test@test.com', + passwordHash: 'hashedPassword', + refreshToken: 'hashed-refresh-token', + isActive: true, + role: 'user', + }; + mockUsersService.findOne.mockResolvedValue(user); + mockUsersService.findByEmail.mockResolvedValue(user as any); + jest.spyOn(bcrypt, 'compare').mockResolvedValue(false); + + await expect( + authService.refresh('1', 'raw-refresh-token'), + ).rejects.toThrow(UnauthorizedException); + }); }); describe('logout', () => { it('should clear the refresh token from the DB', async () => { - await authService.logout('1'); - expect(mockUsersService.updateRefreshToken).toHaveBeenCalledWith('1', null); - }); + await authService.logout('1'); + expect(mockUsersService.updateRefreshToken).toHaveBeenCalledWith( + '1', + null, + ); + }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/admin/admin-user-management.service.ts b/backend/src/admin/admin-user-management.service.ts index c0c616ce..99b6f087 100644 --- a/backend/src/admin/admin-user-management.service.ts +++ b/backend/src/admin/admin-user-management.service.ts @@ -1,35 +1,63 @@ // #998 – Admin: user suspension, platform stats & audit log import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -export interface UserSuspension { userId: string; suspended: boolean; reason: string; suspendedAt: Date; } -export interface PlatformStats { totalUsers: number; activeShipments: number; totalRevenue: number; openDisputes: number; } +export interface UserSuspension { + userId: string; + suspended: boolean; + reason: string; + suspendedAt: Date; +} +export interface PlatformStats { + totalUsers: number; + activeShipments: number; + totalRevenue: number; + openDisputes: number; +} @Injectable() export class AdminUserManagementService { private readonly logger = new Logger(AdminUserManagementService.name); private readonly suspensions = new Map(); - async suspendUser(adminId: string, userId: string, reason: string): Promise { + suspendUser( + adminId: string, + userId: string, + reason: string, + ): Promise { this.logger.log(`Admin ${adminId} suspending user ${userId}`); - const record: UserSuspension = { userId, suspended: true, reason, suspendedAt: new Date() }; + const record: UserSuspension = { + userId, + suspended: true, + reason, + suspendedAt: new Date(), + }; this.suspensions.set(userId, record); - return record; + return Promise.resolve(record); } - async unsuspendUser(adminId: string, userId: string): Promise { + unsuspendUser(adminId: string, userId: string): Promise { const record = this.suspensions.get(userId); if (!record) throw new NotFoundException('No suspension record found'); record.suspended = false; this.logger.log(`Admin ${adminId} unsuspended user ${userId}`); - return record; + return Promise.resolve(record); } - async getPlatformStats(): Promise { - return { totalUsers: 0, activeShipments: 0, totalRevenue: 0, openDisputes: 0 }; + getPlatformStats(): Promise { + return Promise.resolve({ + totalUsers: 0, + activeShipments: 0, + totalRevenue: 0, + openDisputes: 0, + }); } - async getAuditLog(page = 1, limit = 20): Promise<{ entries: unknown[]; total: number }> { - void page; void limit; - return { entries: [], total: 0 }; + getAuditLog( + page = 1, + limit = 20, + ): Promise<{ entries: unknown[]; total: number }> { + void page; + void limit; + return Promise.resolve({ entries: [], total: 0 }); } } diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index 91dbd69e..404fa72c 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -15,18 +15,37 @@ export interface ShipmentAnalytics { export class AnalyticsService { private readonly logger = new Logger(AnalyticsService.name); - async getUserAnalytics(userId: string, from: string, to: string): Promise { + getUserAnalytics( + userId: string, + from: string, + to: string, + ): Promise { this.logger.log(`Analytics user=${userId} ${from}–${to}`); - return { userId, totalShipments: 0, totalSpend: 0, totalRevenue: 0, onTimeRate: 0, cancelledCount: 0, dateRange: { from, to } }; + return Promise.resolve({ + userId, + totalShipments: 0, + totalSpend: 0, + totalRevenue: 0, + onTimeRate: 0, + cancelledCount: 0, + dateRange: { from, to }, + }); } - async exportCsv(userId: string): Promise { + exportCsv(userId: string): Promise { this.logger.log(`CSV export for user ${userId}`); - return 'id,shipmentId,status,amount,createdAt -'; + return Promise.resolve('id,shipmentId,status,amount,createdAt\n'); } - async getPlatformStats(): Promise<{ totalUsers: number; totalShipments: number; totalRevenue: number }> { - return { totalUsers: 0, totalShipments: 0, totalRevenue: 0 }; + getPlatformStats(): Promise<{ + totalUsers: number; + totalShipments: number; + totalRevenue: number; + }> { + return Promise.resolve({ + totalUsers: 0, + totalShipments: 0, + totalRevenue: 0, + }); } } diff --git a/backend/src/auth/two-factor.service.ts b/backend/src/auth/two-factor.service.ts index 1e913df6..5620326d 100644 --- a/backend/src/auth/two-factor.service.ts +++ b/backend/src/auth/two-factor.service.ts @@ -5,27 +5,17 @@ import { Inject, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { generateSecret, verify, generateURI } from 'otplib'; import { Repository, IsNull } from 'typeorm'; +import { generateSecret, generateURI } from 'otplib'; import { authenticator } from '@otplib/preset-v11'; import * as qrcode from 'qrcode'; import * as bcrypt from 'bcrypt'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; -import { TOTP, generateURI } from 'otplib'; -// import { authenticator } from 'otplib'; -import { authenticator } from '@otplib/preset-v11'; -import * as qrcode from 'qrcode'; -import * as bcrypt from 'bcrypt'; -import { Redis } from 'ioredis'; import { User } from '../users/entities/user.entity'; import { TwoFactorRecovery } from '../users/entities/two-factor-recovery.entity'; -import { IsNull } from 'typeorm'; - -const authenticator = new TOTP(); -const SETUP_TTL_MS = 10 * 60 * 1000; // 10 minutes +const SETUP_TTL_MS = 10 * 60 * 1000; @Injectable() export class TwoFactorService { diff --git a/backend/src/carriers/fleet.service.ts b/backend/src/carriers/fleet.service.ts index e48db88c..d8247fad 100644 --- a/backend/src/carriers/fleet.service.ts +++ b/backend/src/carriers/fleet.service.ts @@ -1,8 +1,19 @@ // #982 – Carrier fleet management: trucks, availability & service areas import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -export interface TruckEntry { id: string; carrierId: string; type: string; plateNumber: string; capacity: number; available: boolean; } -export interface ServiceArea { carrierId: string; regions: string[]; updatedAt: Date; } +export interface TruckEntry { + id: string; + carrierId: string; + type: string; + plateNumber: string; + capacity: number; + available: boolean; +} +export interface ServiceArea { + carrierId: string; + regions: string[]; + updatedAt: Date; +} @Injectable() export class FleetService { @@ -10,25 +21,48 @@ export class FleetService { private readonly trucks = new Map(); private readonly areas = new Map(); - async addTruck(carrierId: string, type: string, plateNumber: string, capacity: number): Promise { + addTruck( + carrierId: string, + type: string, + plateNumber: string, + capacity: number, + ): Promise { if (!plateNumber) throw new BadRequestException('Plate number required'); - const truck: TruckEntry = { id: `truck_${Date.now()}`, carrierId, type, plateNumber, capacity, available: true }; + const truck: TruckEntry = { + id: `truck_${Date.now()}`, + carrierId, + type, + plateNumber, + capacity, + available: true, + }; const fleet = this.trucks.get(carrierId) ?? []; - fleet.push(truck); this.trucks.set(carrierId, fleet); + fleet.push(truck); + this.trucks.set(carrierId, fleet); this.logger.log(`Truck ${plateNumber} added for carrier ${carrierId}`); - return truck; + return Promise.resolve(truck); } - async getFleet(carrierId: string): Promise { return this.trucks.get(carrierId) ?? []; } + getFleet(carrierId: string): Promise { + return Promise.resolve(this.trucks.get(carrierId) ?? []); + } - async setAvailability(carrierId: string, truckId: string, available: boolean): Promise { - const truck = (this.trucks.get(carrierId) ?? []).find(t => t.id === truckId); + setAvailability( + carrierId: string, + truckId: string, + available: boolean, + ): Promise { + const truck = (this.trucks.get(carrierId) ?? []).find( + (t) => t.id === truckId, + ); if (!truck) throw new BadRequestException('Truck not found'); - truck.available = available; return truck; + truck.available = available; + return Promise.resolve(truck); } - async setServiceAreas(carrierId: string, regions: string[]): Promise { + setServiceAreas(carrierId: string, regions: string[]): Promise { const area: ServiceArea = { carrierId, regions, updatedAt: new Date() }; - this.areas.set(carrierId, area); return area; + this.areas.set(carrierId, area); + return Promise.resolve(area); } } diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts index 6fa9f513..187b53a5 100644 --- a/backend/src/dashboard/dashboard.service.ts +++ b/backend/src/dashboard/dashboard.service.ts @@ -15,13 +15,24 @@ export interface DashboardSummary { export class DashboardService { private readonly logger = new Logger(DashboardService.name); - async getSummary(userId: string, role: string): Promise { + getSummary(userId: string, role: string): Promise { this.logger.log(`Dashboard summary user=${userId} role=${role}`); - return { totalShipments: 0, activeShipments: 0, totalSpend: 0, totalEarnings: 0, onTimeRate: 0, pendingBids: 0, openDisputes: 0 }; + return Promise.resolve({ + totalShipments: 0, + activeShipments: 0, + totalSpend: 0, + totalEarnings: 0, + onTimeRate: 0, + pendingBids: 0, + openDisputes: 0, + }); } - async getActivityFeed(userId: string, limit = 10): Promise<{ type: string; message: string; createdAt: Date }[]> { + getActivityFeed( + userId: string, + limit = 10, + ): Promise<{ type: string; message: string; createdAt: Date }[]> { this.logger.log(`Activity feed user=${userId} limit=${limit}`); - return []; + return Promise.resolve([]); } } diff --git a/backend/src/disputes/evidence.service.ts b/backend/src/disputes/evidence.service.ts index c9c8cc0b..59c6cc8d 100644 --- a/backend/src/disputes/evidence.service.ts +++ b/backend/src/disputes/evidence.service.ts @@ -1,7 +1,19 @@ // #986 – Dispute evidence: upload, list & mediation tracking -import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; -export interface DisputeEvidence { id: string; disputeId: string; uploadedBy: string; fileUrl: string; description: string; createdAt: Date; } +export interface DisputeEvidence { + id: string; + disputeId: string; + uploadedBy: string; + fileUrl: string; + description: string; + createdAt: Date; +} export type MediationStatus = 'pending' | 'in_review' | 'resolved'; @Injectable() @@ -9,22 +21,37 @@ export class EvidenceService { private readonly logger = new Logger(EvidenceService.name); private readonly evidence = new Map(); - async addEvidence(disputeId: string, userId: string, fileUrl: string, description: string): Promise { + addEvidence( + disputeId: string, + userId: string, + fileUrl: string, + description: string, + ): Promise { if (!fileUrl) throw new BadRequestException('File URL required'); - const ev: DisputeEvidence = { id: `ev_${Date.now()}`, disputeId, uploadedBy: userId, fileUrl, description, createdAt: new Date() }; + const ev: DisputeEvidence = { + id: `ev_${Date.now()}`, + disputeId, + uploadedBy: userId, + fileUrl, + description, + createdAt: new Date(), + }; const list = this.evidence.get(disputeId) ?? []; list.push(ev); this.evidence.set(disputeId, list); this.logger.log(`Evidence added to dispute ${disputeId} by ${userId}`); - return ev; + return Promise.resolve(ev); } - async listEvidence(disputeId: string): Promise { - return this.evidence.get(disputeId) ?? []; + listEvidence(disputeId: string): Promise { + return Promise.resolve(this.evidence.get(disputeId) ?? []); } - async getMediationStatus(disputeId: string): Promise<{ disputeId: string; status: MediationStatus }> { - if (!this.evidence.has(disputeId)) throw new NotFoundException('Dispute not found'); - return { disputeId, status: 'pending' }; + getMediationStatus( + disputeId: string, + ): Promise<{ disputeId: string; status: MediationStatus }> { + if (!this.evidence.has(disputeId)) + throw new NotFoundException('Dispute not found'); + return Promise.resolve({ disputeId, status: 'pending' }); } } diff --git a/backend/src/health/health.controller.spec.ts b/backend/src/health/health.controller.spec.ts index cc7f3722..7ebb9b1b 100644 --- a/backend/src/health/health.controller.spec.ts +++ b/backend/src/health/health.controller.spec.ts @@ -64,9 +64,13 @@ describe('HealthController', () => { }, }; - dbHealthIndicator.isHealthy.mockResolvedValue({ database: { status: 'up' } }); + dbHealthIndicator.isHealthy.mockResolvedValue({ + database: { status: 'up' }, + }); smtpHealthIndicator.isHealthy.mockResolvedValue({ smtp: { status: 'up' } }); - cloudinaryHealthIndicator.isHealthy.mockResolvedValue({ cloudinary: { status: 'up' } }); + cloudinaryHealthIndicator.isHealthy.mockResolvedValue({ + cloudinary: { status: 'up' }, + }); healthCheckService.check.mockResolvedValue(mockHealthResult); const result = await controller.check(); @@ -85,9 +89,15 @@ describe('HealthController', () => { }, }; - dbHealthIndicator.isHealthy.mockResolvedValue({ database: { status: 'up' } }); - smtpHealthIndicator.isHealthy.mockResolvedValue({ smtp: { status: 'down', message: 'SMTP connection failed' } }); - cloudinaryHealthIndicator.isHealthy.mockResolvedValue({ cloudinary: { status: 'up' } }); + dbHealthIndicator.isHealthy.mockResolvedValue({ + database: { status: 'up' }, + }); + smtpHealthIndicator.isHealthy.mockResolvedValue({ + smtp: { status: 'down', message: 'SMTP connection failed' }, + }); + cloudinaryHealthIndicator.isHealthy.mockResolvedValue({ + cloudinary: { status: 'up' }, + }); healthCheckService.check.mockResolvedValue(mockHealthResult); const result = await controller.check(); diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index 008c73a3..244ed14d 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -20,7 +20,9 @@ export class HealthController { @Get() @Public() @HealthCheck() - @ApiOperation({ summary: 'Application health check with DB and Redis indicators' }) + @ApiOperation({ + summary: 'Application health check with DB and Redis indicators', + }) check() { return this.health.check([ () => this.db.pingCheck('database'), diff --git a/backend/src/health/health.module.ts b/backend/src/health/health.module.ts index dad69945..5cc64a60 100644 --- a/backend/src/health/health.module.ts +++ b/backend/src/health/health.module.ts @@ -8,6 +8,10 @@ import { CloudinaryHealthIndicator } from './indicators/cloudinary.health.indica @Module({ imports: [TerminusModule], controllers: [HealthController], - providers: [DbHealthIndicator, SmtpHealthIndicator, CloudinaryHealthIndicator], + providers: [ + DbHealthIndicator, + SmtpHealthIndicator, + CloudinaryHealthIndicator, + ], }) export class HealthModule {} diff --git a/backend/src/health/indicators/cloudinary.health.indicator.spec.ts b/backend/src/health/indicators/cloudinary.health.indicator.spec.ts index 6f374a9d..e9c66a35 100644 --- a/backend/src/health/indicators/cloudinary.health.indicator.spec.ts +++ b/backend/src/health/indicators/cloudinary.health.indicator.spec.ts @@ -22,7 +22,9 @@ describe('CloudinaryHealthIndicator', () => { ], }).compile(); - indicator = module.get(CloudinaryHealthIndicator); + indicator = module.get( + CloudinaryHealthIndicator, + ); configService = module.get(ConfigService); jest.clearAllMocks(); @@ -101,7 +103,9 @@ describe('CloudinaryHealthIndicator', () => { return config[key]; }); - (cloudinary.api.ping as jest.Mock).mockRejectedValue(new Error('API Error')); + (cloudinary.api.ping as jest.Mock).mockRejectedValue( + new Error('API Error'), + ); const result = await indicator.isHealthy('cloudinary'); diff --git a/backend/src/health/indicators/cloudinary.health.indicator.ts b/backend/src/health/indicators/cloudinary.health.indicator.ts index b10a2baf..f418f6a5 100644 --- a/backend/src/health/indicators/cloudinary.health.indicator.ts +++ b/backend/src/health/indicators/cloudinary.health.indicator.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { HealthCheckResult } from '@nestjs/terminus'; +import { HealthIndicatorResult } from '@nestjs/terminus'; import { v2 as cloudinary } from 'cloudinary'; @Injectable() @@ -13,9 +13,9 @@ export class CloudinaryHealthIndicator { }); } - async isHealthy(key: string): Promise { + async isHealthy(key: string): Promise { try { - const result = await cloudinary.api.ping(); + const result = (await cloudinary.api.ping()) as { status: string }; if (result && result.status === 'ok') { return { [key]: { @@ -33,7 +33,10 @@ export class CloudinaryHealthIndicator { return { [key]: { status: 'down', - message: error instanceof Error ? error.message : 'Cloudinary connection failed', + message: + error instanceof Error + ? error.message + : 'Cloudinary connection failed', }, }; } diff --git a/backend/src/health/indicators/db.health.indicator.ts b/backend/src/health/indicators/db.health.indicator.ts index 151713e9..1f581d5e 100644 --- a/backend/src/health/indicators/db.health.indicator.ts +++ b/backend/src/health/indicators/db.health.indicator.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { TypeOrmHealthIndicator } from '@nestjs/terminus'; -import { HealthCheckResult } from '@nestjs/terminus'; +import { HealthIndicatorResult } from '@nestjs/terminus'; @Injectable() export class DbHealthIndicator { constructor(private db: TypeOrmHealthIndicator) {} - async isHealthy(key: string): Promise { + async isHealthy(key: string): Promise { return this.db.pingCheck(key); } } diff --git a/backend/src/health/indicators/smtp.health.indicator.spec.ts b/backend/src/health/indicators/smtp.health.indicator.spec.ts index 94dba91b..851cbc91 100644 --- a/backend/src/health/indicators/smtp.health.indicator.spec.ts +++ b/backend/src/health/indicators/smtp.health.indicator.spec.ts @@ -8,7 +8,7 @@ jest.mock('nodemailer'); describe('SmtpHealthIndicator', () => { let indicator: SmtpHealthIndicator; let configService: jest.Mocked; - let mockTransporter: any; + let mockTransporter: { verify: jest.Mock }; beforeEach(async () => { mockTransporter = { diff --git a/backend/src/health/indicators/smtp.health.indicator.ts b/backend/src/health/indicators/smtp.health.indicator.ts index 46a7a7b0..61f896d4 100644 --- a/backend/src/health/indicators/smtp.health.indicator.ts +++ b/backend/src/health/indicators/smtp.health.indicator.ts @@ -1,16 +1,19 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { HealthCheckResult } from '@nestjs/terminus'; +import { HealthIndicatorResult } from '@nestjs/terminus'; import * as nodemailer from 'nodemailer'; @Injectable() export class SmtpHealthIndicator { constructor(private configService: ConfigService) {} - async isHealthy(key: string): Promise { + async isHealthy(key: string): Promise { try { const transporter = nodemailer.createTransport({ - host: this.configService.get('MAIL_HOST', 'sandbox.smtp.mailtrap.io'), + host: this.configService.get( + 'MAIL_HOST', + 'sandbox.smtp.mailtrap.io', + ), port: this.configService.get('MAIL_PORT', 2525), auth: { user: this.configService.get('MAIL_USER'), @@ -28,7 +31,8 @@ export class SmtpHealthIndicator { return { [key]: { status: 'down', - message: error instanceof Error ? error.message : 'SMTP connection failed', + message: + error instanceof Error ? error.message : 'SMTP connection failed', }, }; } diff --git a/backend/src/location-updates/tracking.service.ts b/backend/src/location-updates/tracking.service.ts index ad6b5873..9976455b 100644 --- a/backend/src/location-updates/tracking.service.ts +++ b/backend/src/location-updates/tracking.service.ts @@ -1,33 +1,59 @@ // #984 – Shipment tracking: location updates, timeline & ETA import { Injectable, Logger } from '@nestjs/common'; -export interface LocationUpdate { shipmentId: string; lat: number; lng: number; timestamp: Date; driverNote?: string; } -export interface TrackingTimeline { shipmentId: string; events: { label: string; timestamp: Date; completed: boolean }[]; estimatedArrival: Date; } +export interface LocationUpdate { + shipmentId: string; + lat: number; + lng: number; + timestamp: Date; + driverNote?: string; +} +export interface TrackingTimeline { + shipmentId: string; + events: { label: string; timestamp: Date; completed: boolean }[]; + estimatedArrival: Date; +} @Injectable() export class TrackingService { private readonly logger = new Logger(TrackingService.name); private readonly updates = new Map(); - async recordLocation(shipmentId: string, lat: number, lng: number, driverNote?: string): Promise { - const update: LocationUpdate = { shipmentId, lat, lng, timestamp: new Date(), driverNote }; + recordLocation( + shipmentId: string, + lat: number, + lng: number, + driverNote?: string, + ): Promise { + const update: LocationUpdate = { + shipmentId, + lat, + lng, + timestamp: new Date(), + driverNote, + }; const history = this.updates.get(shipmentId) ?? []; - history.push(update); this.updates.set(shipmentId, history); + history.push(update); + this.updates.set(shipmentId, history); this.logger.log(`Location for shipment ${shipmentId}: (${lat}, ${lng})`); - return update; + return Promise.resolve(update); } - async getTimeline(shipmentId: string): Promise { + getTimeline(shipmentId: string): Promise { const history = this.updates.get(shipmentId) ?? []; - return { + return Promise.resolve({ shipmentId, - events: history.map(u => ({ label: `Update${u.driverNote ? ': ' + u.driverNote : ''}`, timestamp: u.timestamp, completed: true })), + events: history.map((u) => ({ + label: `Update${u.driverNote ? ': ' + u.driverNote : ''}`, + timestamp: u.timestamp, + completed: true, + })), estimatedArrival: new Date(Date.now() + 2 * 60 * 60 * 1000), - }; + }); } - async getLatestLocation(shipmentId: string): Promise { + getLatestLocation(shipmentId: string): Promise { const history = this.updates.get(shipmentId) ?? []; - return history[history.length - 1] ?? null; + return Promise.resolve(history[history.length - 1] ?? null); } } diff --git a/backend/src/organizations/organizations.service.ts b/backend/src/organizations/organizations.service.ts index efdff4db..04dc3190 100644 --- a/backend/src/organizations/organizations.service.ts +++ b/backend/src/organizations/organizations.service.ts @@ -1,38 +1,69 @@ // #996 – Organization/company accounts: multi-user shipper teams -import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; -export interface OrgMember { userId: string; role: string; } -export interface Organization { id: string; name: string; ownerId: string; members: OrgMember[]; } +export interface OrgMember { + userId: string; + role: string; +} +export interface Organization { + id: string; + name: string; + ownerId: string; + members: OrgMember[]; +} @Injectable() export class OrganizationsService { private readonly logger = new Logger(OrganizationsService.name); private readonly orgs = new Map(); - async create(ownerId: string, name: string): Promise { - const org: Organization = { id: `org_${Date.now()}`, name, ownerId, members: [{ userId: ownerId, role: 'owner' }] }; + create(ownerId: string, name: string): Promise { + const org: Organization = { + id: `org_${Date.now()}`, + name, + ownerId, + members: [{ userId: ownerId, role: 'owner' }], + }; this.orgs.set(org.id, org); this.logger.log(`Org created: ${org.id}`); - return org; + return Promise.resolve(org); } - async inviteMember(orgId: string, requesterId: string, inviteeId: string, role: string): Promise { + inviteMember( + orgId: string, + requesterId: string, + inviteeId: string, + role: string, + ): Promise { const org = this.orgs.get(orgId); if (!org) throw new NotFoundException('Organization not found'); - if (org.ownerId !== requesterId) throw new BadRequestException('Only owner can invite members'); + if (org.ownerId !== requesterId) + throw new BadRequestException('Only owner can invite members'); org.members.push({ userId: inviteeId, role }); - return org; + return Promise.resolve(org); } - async removeMember(orgId: string, requesterId: string, memberId: string): Promise { + removeMember( + orgId: string, + requesterId: string, + memberId: string, + ): Promise { const org = this.orgs.get(orgId); if (!org) throw new NotFoundException('Organization not found'); - if (org.ownerId !== requesterId) throw new BadRequestException('Only owner can remove members'); - org.members = org.members.filter(m => m.userId !== memberId); - return org; + if (org.ownerId !== requesterId) + throw new BadRequestException('Only owner can remove members'); + org.members = org.members.filter((m) => m.userId !== memberId); + return Promise.resolve(org); } - async findByOwner(ownerId: string): Promise { - return [...this.orgs.values()].filter(o => o.ownerId === ownerId); + findByOwner(ownerId: string): Promise { + return Promise.resolve( + [...this.orgs.values()].filter((o) => o.ownerId === ownerId), + ); } } diff --git a/backend/src/payments/payments.service.ts b/backend/src/payments/payments.service.ts index 0905401d..e3981739 100644 --- a/backend/src/payments/payments.service.ts +++ b/backend/src/payments/payments.service.ts @@ -1,32 +1,64 @@ // #994 – Stripe checkout, webhook handler & invoice generation stubs import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -export interface PaymentIntent { id: string; clientSecret: string; amount: number; currency: string; } -export interface InvoiceResult { invoiceId: string; pdfUrl: string; } +export interface PaymentIntent { + id: string; + clientSecret: string; + amount: number; + currency: string; +} +export interface InvoiceResult { + invoiceId: string; + pdfUrl: string; +} @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); - async createPaymentIntent(shipmentId: string, amountCents: number): Promise { - if (amountCents <= 0) throw new BadRequestException('Amount must be positive'); - this.logger.log(`Creating payment intent for shipment ${shipmentId}: ${amountCents} cents`); - return { id: `pi_${Date.now()}`, clientSecret: `pi_${Date.now()}_secret`, amount: amountCents, currency: 'usd' }; + createPaymentIntent( + shipmentId: string, + amountCents: number, + ): Promise { + if (amountCents <= 0) + throw new BadRequestException('Amount must be positive'); + this.logger.log( + `Creating payment intent for shipment ${shipmentId}: ${amountCents} cents`, + ); + return Promise.resolve({ + id: `pi_${Date.now()}`, + clientSecret: `pi_${Date.now()}_secret`, + amount: amountCents, + currency: 'usd', + }); } - async handleWebhook(payload: string, signature: string): Promise<{ received: boolean }> { + handleWebhook( + payload: string, + signature: string, + ): Promise<{ received: boolean }> { this.logger.log(`Webhook received sig=${signature.slice(0, 10)}`); void payload; - return { received: true }; + return Promise.resolve({ received: true }); } - async generateInvoice(shipmentId: string, userId: string): Promise { - this.logger.log(`Generating invoice for shipment ${shipmentId} user ${userId}`); - return { invoiceId: `inv_${Date.now()}`, pdfUrl: `/invoices/${shipmentId}.pdf` }; + generateInvoice(shipmentId: string, userId: string): Promise { + this.logger.log( + `Generating invoice for shipment ${shipmentId} user ${userId}`, + ); + return Promise.resolve({ + invoiceId: `inv_${Date.now()}`, + pdfUrl: `/invoices/${shipmentId}.pdf`, + }); } - async releaseEarnings(shipmentId: string, carrierId: string): Promise<{ transferred: boolean }> { - this.logger.log(`Releasing earnings for shipment ${shipmentId} carrier ${carrierId}`); - return { transferred: true }; + releaseEarnings( + shipmentId: string, + carrierId: string, + ): Promise<{ transferred: boolean }> { + this.logger.log( + `Releasing earnings for shipment ${shipmentId} carrier ${carrierId}`, + ); + return Promise.resolve({ transferred: true }); } } diff --git a/backend/src/quotes/quotes.service.ts b/backend/src/quotes/quotes.service.ts index fc8e8afc..adf969c4 100644 --- a/backend/src/quotes/quotes.service.ts +++ b/backend/src/quotes/quotes.service.ts @@ -1,23 +1,54 @@ // #980 – Instant rate calculator: zone-based price estimation import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -export interface QuoteEstimate { minPrice: number; maxPrice: number; currency: string; estimatedDays: number; breakdown: { base: number; distanceFee: number; weightFee: number }; } +export interface QuoteEstimate { + minPrice: number; + maxPrice: number; + currency: string; + estimatedDays: number; + breakdown: { base: number; distanceFee: number; weightFee: number }; +} -const ZONE_RATES: Record = { local: 1.0, regional: 1.5, national: 2.2 }; +const ZONE_RATES: Record = { + local: 1.0, + regional: 1.5, + national: 2.2, +}; @Injectable() export class QuotesService { private readonly logger = new Logger(QuotesService.name); - async estimate(origin: string, destination: string, weightKg: number, cargoCategory: string): Promise { - if (!origin || !destination) throw new BadRequestException('Origin and destination required'); + estimate( + origin: string, + destination: string, + weightKg: number, + cargoCategory: string, + ): Promise { + if (!origin || !destination) + throw new BadRequestException('Origin and destination required'); if (weightKg <= 0) throw new BadRequestException('Weight must be positive'); void cargoCategory; - const zone = origin === destination ? 'local' : origin.slice(0, 2) === destination.slice(0, 2) ? 'regional' : 'national'; + const zone = + origin === destination + ? 'local' + : origin.slice(0, 2) === destination.slice(0, 2) + ? 'regional' + : 'national'; const rate = ZONE_RATES[zone] ?? 2.0; - const base = 50, distanceFee = rate * 30, weightFee = weightKg * 0.5; + const base = 50, + distanceFee = rate * 30, + weightFee = weightKg * 0.5; const total = base + distanceFee + weightFee; - this.logger.log(`Quote ${origin}->${destination} ${weightKg}kg zone=${zone} ~$${total.toFixed(2)}`); - return { minPrice: Math.round(total * 0.9), maxPrice: Math.round(total * 1.1), currency: 'USD', estimatedDays: zone === 'local' ? 1 : zone === 'regional' ? 3 : 7, breakdown: { base, distanceFee, weightFee } }; + this.logger.log( + `Quote ${origin}->${destination} ${weightKg}kg zone=${zone} ~$${total.toFixed(2)}`, + ); + return Promise.resolve({ + minPrice: Math.round(total * 0.9), + maxPrice: Math.round(total * 1.1), + currency: 'USD', + estimatedDays: zone === 'local' ? 1 : zone === 'regional' ? 3 : 7, + breakdown: { base, distanceFee, weightFee }, + }); } } diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts index 0a2d82e3..8d3ee7c0 100644 --- a/backend/src/reports/reports.service.ts +++ b/backend/src/reports/reports.service.ts @@ -3,27 +3,47 @@ import { Injectable, Logger } from '@nestjs/common'; export type ReportFormat = 'pdf' | 'csv'; export type ReportStatus = 'pending' | 'ready' | 'failed'; -export interface ReportJob { jobId: string; userId: string; format: ReportFormat; status: ReportStatus; downloadUrl?: string; } +export interface ReportJob { + jobId: string; + userId: string; + format: ReportFormat; + status: ReportStatus; + downloadUrl?: string; +} @Injectable() export class ReportsService { private readonly logger = new Logger(ReportsService.name); private readonly jobs = new Map(); - async generateReport(userId: string, format: ReportFormat, dateFrom: string, dateTo: string): Promise { + generateReport( + userId: string, + format: ReportFormat, + dateFrom: string, + dateTo: string, + ): Promise { const jobId = `report_${Date.now()}`; const job: ReportJob = { jobId, userId, format, status: 'pending' }; this.jobs.set(jobId, job); - this.logger.log(`Report job ${jobId} queued for user ${userId} (${format}) ${dateFrom}–${dateTo}`); - return job; + this.logger.log( + `Report job ${jobId} queued for user ${userId} (${format}) ${dateFrom}–${dateTo}`, + ); + return Promise.resolve(job); } - async getStatus(jobId: string): Promise { - return this.jobs.get(jobId) ?? { jobId, userId: '', format: 'csv', status: 'failed' }; + getStatus(jobId: string): Promise { + return Promise.resolve( + this.jobs.get(jobId) ?? { + jobId, + userId: '', + format: 'csv', + status: 'failed', + }, + ); } - async sendWeeklyDigest(userId: string): Promise<{ sent: boolean }> { + sendWeeklyDigest(userId: string): Promise<{ sent: boolean }> { this.logger.log(`Weekly digest sent to user ${userId}`); - return { sent: true }; + return Promise.resolve({ sent: true }); } } diff --git a/backend/src/reviews/review-stats.service.ts b/backend/src/reviews/review-stats.service.ts index 956d4f66..87142a3c 100644 --- a/backend/src/reviews/review-stats.service.ts +++ b/backend/src/reviews/review-stats.service.ts @@ -1,15 +1,25 @@ // #990 – Carrier reputation: aggregate review stats per carrier import { Injectable, Logger } from '@nestjs/common'; -export interface ReviewStats { carrierId: string; averageRating: number; totalReviews: number; ratingBreakdown: Record; } +export interface ReviewStats { + carrierId: string; + averageRating: number; + totalReviews: number; + ratingBreakdown: Record; +} @Injectable() export class ReviewStatsService { private readonly logger = new Logger(ReviewStatsService.name); - async getCarrierStats(carrierId: string): Promise { + getCarrierStats(carrierId: string): Promise { this.logger.log(`Computing review stats for carrier ${carrierId}`); - return { carrierId, averageRating: 0, totalReviews: 0, ratingBreakdown: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } }; + return Promise.resolve({ + carrierId, + averageRating: 0, + totalReviews: 0, + ratingBreakdown: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }, + }); } computeAverage(ratings: number[]): number { diff --git a/backend/src/shipments/shipments.module.ts b/backend/src/shipments/shipments.module.ts index 65e0937e..53df4976 100644 --- a/backend/src/shipments/shipments.module.ts +++ b/backend/src/shipments/shipments.module.ts @@ -9,7 +9,10 @@ import { EtaService } from './eta.service'; import { CacheModule } from '../cache/cache.module'; @Module({ - imports: [TypeOrmModule.forFeature([Shipment, ShipmentStatusHistory]), CacheModule], + imports: [ + TypeOrmModule.forFeature([Shipment, ShipmentStatusHistory]), + CacheModule, + ], controllers: [ShipmentsController, QuotesController], providers: [ShipmentsService, EtaService], exports: [ShipmentsService], diff --git a/backend/src/stellar-anchor/stellar-anchor.service.ts b/backend/src/stellar-anchor/stellar-anchor.service.ts index d06ab6b7..e3b289f2 100644 --- a/backend/src/stellar-anchor/stellar-anchor.service.ts +++ b/backend/src/stellar-anchor/stellar-anchor.service.ts @@ -1,34 +1,53 @@ // #1007 – Stellar Anchor: SEP-6/SEP-24 deposit & withdrawal flow stubs import { Injectable, Logger } from '@nestjs/common'; -export interface AnchorDepositResult { txId: string; status: string; } -export interface AnchorWithdrawResult { txId: string; status: string; } +export interface AnchorDepositResult { + txId: string; + status: string; +} +export interface AnchorWithdrawResult { + txId: string; + status: string; +} @Injectable() export class StellarAnchorService { private readonly logger = new Logger(StellarAnchorService.name); - async initiateDeposit( + initiateDeposit( userId: string, assetCode: string, amount: number, ): Promise { - this.logger.log(`SEP-24 deposit: user=${userId} asset=${assetCode} amount=${amount}`); - return { txId: `anchor-deposit-${Date.now()}`, status: 'pending_external' }; + this.logger.log( + `SEP-24 deposit: user=${userId} asset=${assetCode} amount=${amount}`, + ); + return Promise.resolve({ + txId: `anchor-deposit-${Date.now()}`, + status: 'pending_external', + }); } - async initiateWithdrawal( + initiateWithdrawal( userId: string, assetCode: string, amount: number, destinationAddress: string, ): Promise { - this.logger.log(`SEP-6 withdrawal: user=${userId} dest=${destinationAddress}`); - void assetCode; void amount; - return { txId: `anchor-withdraw-${Date.now()}`, status: 'pending_anchor' }; + this.logger.log( + `SEP-6 withdrawal: user=${userId} dest=${destinationAddress}`, + ); + void assetCode; + void amount; + return Promise.resolve({ + txId: `anchor-withdraw-${Date.now()}`, + status: 'pending_anchor', + }); } - async getTransactionStatus(txId: string): Promise<{ txId: string; status: string }> { - return { txId, status: 'completed' }; + getTransactionStatus( + txId: string, + ): Promise<{ txId: string; status: string }> { + return Promise.resolve({ txId, status: 'completed' }); } } diff --git a/backend/src/stellar-escrow/escrow-payment.service.ts b/backend/src/stellar-escrow/escrow-payment.service.ts index 6f766c60..46ce5e9e 100644 --- a/backend/src/stellar-escrow/escrow-payment.service.ts +++ b/backend/src/stellar-escrow/escrow-payment.service.ts @@ -1,33 +1,56 @@ // #1009 – Soroban escrow: on-chain payment hold & release for shipments import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -export interface EscrowHold { shipmentId: string; amount: number; txHash: string; status: 'held' | 'released' | 'refunded'; } +export interface EscrowHold { + shipmentId: string; + amount: number; + txHash: string; + status: 'held' | 'released' | 'refunded'; +} @Injectable() export class EscrowPaymentService { private readonly logger = new Logger(EscrowPaymentService.name); private readonly escrows = new Map(); - async holdPayment(shipmentId: string, amount: number, shipperPublicKey: string): Promise { + holdPayment( + shipmentId: string, + amount: number, + shipperPublicKey: string, + ): Promise { if (amount <= 0) throw new BadRequestException('Amount must be positive'); - this.logger.log(`Holding ${amount} XLM in escrow for shipment ${shipmentId} (shipper: ${shipperPublicKey})`); - const hold: EscrowHold = { shipmentId, amount, txHash: `escrow-hold-${Date.now()}`, status: 'held' }; + this.logger.log( + `Holding ${amount} XLM in escrow for shipment ${shipmentId} (shipper: ${shipperPublicKey})`, + ); + const hold: EscrowHold = { + shipmentId, + amount, + txHash: `escrow-hold-${Date.now()}`, + status: 'held', + }; this.escrows.set(shipmentId, hold); - return hold; + return Promise.resolve(hold); } - async releasePayment(shipmentId: string, carrierPublicKey: string): Promise { + releasePayment( + shipmentId: string, + carrierPublicKey: string, + ): Promise { const escrow = this.escrows.get(shipmentId); - if (!escrow) throw new BadRequestException('No escrow found for this shipment'); - this.logger.log(`Releasing escrow for shipment ${shipmentId} to carrier ${carrierPublicKey}`); + if (!escrow) + throw new BadRequestException('No escrow found for this shipment'); + this.logger.log( + `Releasing escrow for shipment ${shipmentId} to carrier ${carrierPublicKey}`, + ); escrow.status = 'released'; - return escrow; + return Promise.resolve(escrow); } - async refundPayment(shipmentId: string): Promise { + refundPayment(shipmentId: string): Promise { const escrow = this.escrows.get(shipmentId); - if (!escrow) throw new BadRequestException('No escrow found for this shipment'); + if (!escrow) + throw new BadRequestException('No escrow found for this shipment'); escrow.status = 'refunded'; - return escrow; + return Promise.resolve(escrow); } } diff --git a/backend/src/wallets/wallets.service.ts b/backend/src/wallets/wallets.service.ts index 01c05e8a..dae53241 100644 --- a/backend/src/wallets/wallets.service.ts +++ b/backend/src/wallets/wallets.service.ts @@ -1,30 +1,48 @@ // #1008 – Wallet linking, identity contract integration & blockchain verification import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -export interface LinkedWallet { userId: string; publicKey: string; verified: boolean; linkedAt: Date; } +export interface LinkedWallet { + userId: string; + publicKey: string; + verified: boolean; + linkedAt: Date; +} @Injectable() export class WalletsService { private readonly logger = new Logger(WalletsService.name); private readonly wallets = new Map(); - async linkWallet(userId: string, publicKey: string, signedChallenge: string): Promise { + linkWallet( + userId: string, + publicKey: string, + signedChallenge: string, + ): Promise { if (!publicKey.startsWith('G') || publicKey.length !== 56) { throw new BadRequestException('Invalid Stellar public key'); } - if (!signedChallenge) throw new BadRequestException('Signed challenge required for verification'); + if (!signedChallenge) + throw new BadRequestException( + 'Signed challenge required for verification', + ); this.logger.log(`Linking wallet ${publicKey} for user ${userId}`); - const wallet: LinkedWallet = { userId, publicKey, verified: true, linkedAt: new Date() }; + const wallet: LinkedWallet = { + userId, + publicKey, + verified: true, + linkedAt: new Date(), + }; this.wallets.set(userId, wallet); - return wallet; + return Promise.resolve(wallet); } - async getWallet(userId: string): Promise { - return this.wallets.get(userId) ?? null; + getWallet(userId: string): Promise { + return Promise.resolve(this.wallets.get(userId) ?? null); } - async unlinkWallet(userId: string): Promise { + unlinkWallet(userId: string): Promise { this.wallets.delete(userId); this.logger.log(`Unlinked wallet for user ${userId}`); + return Promise.resolve(); } } From 7af7caa692cb937f6d0c09f3f31cc500b0bb4d98 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 27 Jun 2026 18:43:44 +0100 Subject: [PATCH 4/6] fix: add legacy-peer-deps for bullmq/redis conflict and fix frontend JSX syntax errors --- backend/.npmrc | 1 + frontend/app/sandbox/page.tsx | 2 ++ frontend/components/carrier/carrier-profile-card.tsx | 1 - 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 backend/.npmrc diff --git a/backend/.npmrc b/backend/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/backend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/frontend/app/sandbox/page.tsx b/frontend/app/sandbox/page.tsx index 7db6f1b0..27e4a6e4 100644 --- a/frontend/app/sandbox/page.tsx +++ b/frontend/app/sandbox/page.tsx @@ -236,6 +236,8 @@ export default function SandboxPage() {

Select a tier and enter a declared value to see the real-time premium.

+ +
{/* Header with currency toggle */}
diff --git a/frontend/components/carrier/carrier-profile-card.tsx b/frontend/components/carrier/carrier-profile-card.tsx index 4eb6e5c5..79065914 100644 --- a/frontend/components/carrier/carrier-profile-card.tsx +++ b/frontend/components/carrier/carrier-profile-card.tsx @@ -5,7 +5,6 @@ interface Props { carrierId: string; companyName: string; verificationStatus: 'p const COLORS = { pending: 'bg-yellow-100 text-yellow-700', verified: 'bg-green-100 text-green-700', rejected: 'bg-red-100 text-red-700' }; export function CarrierProfileCard({ companyName, verificationStatus, fleet, serviceAreas }: Props) { - void _ => _ as unknown; // satisfy unused carrierId via destructuring return (
From b499755f51b2c857145152c42b26d01fda3183ce Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 27 Jun 2026 18:46:38 +0100 Subject: [PATCH 5/6] fix: update package-lock.json to include bullmq and nestjs/bullmq packages --- backend/package-lock.json | 234 +++++++++++++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 4 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 4b8e3d96..d7044d53 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@nestjs-modules/mailer": "^2.0.2", "@nestjs/axios": "^4.0.0", + "@nestjs/bullmq": "^11.0.4", "@nestjs/cache-manager": "^3.1.3", "@nestjs/common": "^11.1.6", "@nestjs/config": "^4.0.2", @@ -33,6 +34,7 @@ "@types/qrcode": "^1.5.6", "@willsoto/nestjs-prometheus": "^6.0.2", "bcrypt": "^5.1.1", + "bullmq": "^5.79.1", "cache-manager": "^6.4.3", "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", @@ -2975,6 +2977,84 @@ "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==" }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/nice": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", @@ -3404,6 +3484,34 @@ "rxjs": "^7.0.0" } }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", + "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/bullmq": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.4.tgz", + "integrity": "sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.4", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@nestjs/cache-manager": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.1.3.tgz", @@ -7529,6 +7637,61 @@ "node": ">=0.2.0" } }, + "node_modules/bullmq": { + "version": "5.79.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.79.2.tgz", + "integrity": "sha512-FebD+8XCZl/hnS1R4to24L4EAN70XSndKZO0776M36vGRk5MKVhKlNFM8/34zXLXKyYB4QaeIPFhVXSYYGTHpQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.4", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -8458,6 +8621,18 @@ "url": "https://ko-fi.com/intcreator" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12859,6 +13034,12 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -13895,6 +14076,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz", + "integrity": "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/multer": { "version": "1.4.5-lts.1", "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", @@ -14023,7 +14235,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", - "dev": true, "license": "MIT" }, "node_modules/node-addon-api": { @@ -14060,6 +14271,21 @@ } } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -16257,9 +16483,9 @@ } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" From 2866bd30226845abbcd33a77ef7efb940a949d5c Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Sat, 27 Jun 2026 18:53:45 +0100 Subject: [PATCH 6/6] fix: correct User mock objects in specs and fix zodResolver type mismatch in onboarding form --- backend/package/tests/auth/auth.service.spec.ts | 15 ++++++++------- backend/src/auth/auth.service.spec.ts | 4 +--- backend/src/shipments/shipments.service.spec.ts | 1 + backend/src/users/users.service.spec.ts | 4 +--- frontend/app/sandbox/carrier/onboarding/page.tsx | 8 ++++---- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/backend/package/tests/auth/auth.service.spec.ts b/backend/package/tests/auth/auth.service.spec.ts index 6b8d67fb..826ca9a8 100644 --- a/backend/package/tests/auth/auth.service.spec.ts +++ b/backend/package/tests/auth/auth.service.spec.ts @@ -13,6 +13,7 @@ import { ConflictException, UnauthorizedException, } from '@nestjs/common'; +import { UserRole } from '../../../src/common/enums/role.enum'; describe('AuthService', () => { let authService: AuthService; @@ -80,7 +81,7 @@ describe('AuthService', () => { password: 'password', firstName: 'Test', lastName: 'User', - role: 'user', + role: UserRole.SHIPPER, }; const user = { id: '1', @@ -113,7 +114,7 @@ describe('AuthService', () => { password: 'password', firstName: 'Test', lastName: 'User', - role: 'user', + role: UserRole.SHIPPER, }; mockUsersService.create.mockRejectedValue( new ConflictException('Email already exists'), @@ -130,7 +131,7 @@ describe('AuthService', () => { password: '123', firstName: 'Test', lastName: 'User', - role: 'user', + role: UserRole.SHIPPER, }; mockUsersService.create.mockRejectedValue( new BadRequestException('Password is too weak'), @@ -150,7 +151,7 @@ describe('AuthService', () => { passwordHash: 'hashedPassword', refreshToken: null, isActive: true, - role: 'user', + role: UserRole.SHIPPER, }; mockJwtService.signAsync.mockResolvedValue('test-token'); @@ -179,7 +180,7 @@ describe('AuthService', () => { passwordHash: 'hashedPassword', refreshToken: null, isActive: false, - role: 'user', + role: UserRole.SHIPPER, }; await expect(authService.login(user as any)).rejects.toThrow( @@ -196,7 +197,7 @@ describe('AuthService', () => { passwordHash: 'hashedPassword', refreshToken: 'hashed-refresh-token', isActive: true, - role: 'user', + role: UserRole.SHIPPER, }; mockUsersService.findOne.mockResolvedValue(user); mockUsersService.findByEmail.mockResolvedValue(user as any); @@ -223,7 +224,7 @@ describe('AuthService', () => { passwordHash: 'hashedPassword', refreshToken: 'hashed-refresh-token', isActive: true, - role: 'user', + role: UserRole.SHIPPER, }; mockUsersService.findOne.mockResolvedValue(user); mockUsersService.findByEmail.mockResolvedValue(user as any); diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 7388a82d..bcf8494c 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -24,6 +24,7 @@ function makeUser(overrides: Partial = {}): User { isTwoFactorEnabled: false, twoFactorSecret: null, recoveryCodes: [], + avatarUrl: '', walletAddress: null, refreshToken: null, verificationToken: null, @@ -32,9 +33,6 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), - isTwoFactorEnabled: false, - twoFactorSecret: undefined as any, - recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/shipments/shipments.service.spec.ts b/backend/src/shipments/shipments.service.spec.ts index 56212b21..0c420719 100644 --- a/backend/src/shipments/shipments.service.spec.ts +++ b/backend/src/shipments/shipments.service.spec.ts @@ -38,6 +38,7 @@ function makeUser(overrides: Partial = {}): User { updatedAt: new Date(), twoFactorSecret: null, recoveryCodes: [], + avatarUrl: '', ...overrides, }; } diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 29132ca9..68c815fc 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -22,6 +22,7 @@ function makeUser(overrides: Partial = {}): User { isTwoFactorEnabled: false, twoFactorSecret: null, recoveryCodes: [], + avatarUrl: '', walletAddress: null, refreshToken: null, verificationToken: null, @@ -30,9 +31,6 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), - isTwoFactorEnabled: false, - twoFactorSecret: undefined as any, - recoveryCodes: [], ...overrides, }; } diff --git a/frontend/app/sandbox/carrier/onboarding/page.tsx b/frontend/app/sandbox/carrier/onboarding/page.tsx index e3d6fbdb..8d327abb 100644 --- a/frontend/app/sandbox/carrier/onboarding/page.tsx +++ b/frontend/app/sandbox/carrier/onboarding/page.tsx @@ -15,8 +15,8 @@ const step1Schema = z.object({ const step2Schema = z.object({ vehicleTypes: z.string().min(2, 'Vehicle types are required'), - capacityTons: z.coerce.number().positive('Capacity must be positive'), - fleetCount: z.coerce.number().int().positive('Fleet count must be positive'), + capacityTons: z.number().positive('Capacity must be positive'), + fleetCount: z.number().int().positive('Fleet count must be positive'), }); const step3Schema = z.object({ @@ -112,10 +112,10 @@ function Step2Form({ data, onNext, onBack }: { data: Partial; onNext: (d: - + - +