From 3781b71de5bd277adafdfa24ebc30788edc889fb Mon Sep 17 00:00:00 2001 From: Mercy Duru Date: Thu, 25 Jun 2026 23:04:52 +0100 Subject: [PATCH] fix errors --- .../src/addresses/addresses.service.spec.ts | 32 ++++- backend/src/admin/admin.controller.ts | 5 +- backend/src/audit-log/audit-log.controller.ts | 4 +- backend/src/auth/auth.service.spec.ts | 3 + backend/src/auth/two-factor.service.ts | 69 +++++++--- .../src/bids/bids-notifications.service.ts | 15 ++- backend/src/bids/bids.controller.ts | 4 +- backend/src/bids/bids.service.spec.ts | 121 ++++++++++++++---- backend/src/bids/bids.service.ts | 44 +++++-- backend/src/bids/entities/bid.entity.ts | 14 +- .../carrier-certifications.service.spec.ts | 5 +- .../src/carriers/carrier-earnings.service.ts | 15 ++- backend/src/carriers/carriers.controller.ts | 9 +- backend/src/carriers/carriers.service.ts | 32 ++++- backend/src/cloudinary/cloudinary.service.ts | 5 +- backend/src/common/cursor-pagination.util.ts | 11 +- backend/src/documents/documents.service.ts | 9 +- .../messaging/dto/create-conversation.dto.ts | 2 +- backend/src/messaging/messaging.controller.ts | 5 +- backend/src/messaging/messaging.service.ts | 9 +- .../1750678212610-BidCounterOfferAndExpiry.ts | 36 ++++-- .../notification-preferences.controller.ts | 9 +- .../notification-preferences.service.spec.ts | 5 +- .../notification-preferences.service.ts | 7 +- .../dto/notification-query.dto.ts | 10 +- .../entities/notification.entity.ts | 8 +- .../notification-inbox.controller.ts | 14 +- .../notification-inbox.service.ts | 11 +- .../notifications/notifications.gateway.ts | 5 +- .../src/notifications/notifications.module.ts | 8 +- backend/src/push/push.module.ts | 2 +- backend/src/push/push.service.ts | 2 +- backend/src/reviews/reviews.controller.ts | 10 +- backend/src/reviews/reviews.service.spec.ts | 38 +++++- backend/src/reviews/reviews.service.ts | 29 ++++- .../src/shipments/cancellation-fee.service.ts | 18 ++- .../src/shipments/dispute-evidence.service.ts | 18 ++- .../src/shipments/dto/analytics-query.dto.ts | 10 +- .../dto/batch-create-shipments.dto.ts | 7 +- .../src/shipments/dto/create-shipment.dto.ts | 5 +- backend/src/shipments/eta.service.ts | 3 +- .../shipments/shipment-template.service.ts | 18 ++- .../src/shipments/shipments.analytics.spec.ts | 3 + .../src/shipments/shipments.service.spec.ts | 3 + backend/src/sms/sms.module.ts | 2 +- backend/src/sms/sms.service.ts | 2 +- .../entities/two-factor-recovery.entity.ts | 11 +- backend/src/users/users.service.spec.ts | 3 + 48 files changed, 534 insertions(+), 176 deletions(-) diff --git a/backend/src/addresses/addresses.service.spec.ts b/backend/src/addresses/addresses.service.spec.ts index 2dc4ca77..a655d21c 100644 --- a/backend/src/addresses/addresses.service.spec.ts +++ b/backend/src/addresses/addresses.service.spec.ts @@ -31,7 +31,12 @@ describe('AddressesService', () => { describe('create', () => { it('creates an address', async () => { - const dto = { label: 'HQ', address: '1 Main St', city: 'Lagos', country: 'Nigeria' }; + const dto = { + label: 'HQ', + address: '1 Main St', + city: 'Lagos', + country: 'Nigeria', + }; const saved = { id: 'uuid', userId: 'user1', ...dto, isDefault: false }; repo.create.mockReturnValue(saved); repo.save.mockResolvedValue(saved); @@ -42,13 +47,22 @@ describe('AddressesService', () => { }); it('clears other defaults when isDefault=true', async () => { - const dto = { label: 'HQ', address: '1 Main St', city: 'Lagos', country: 'Nigeria', isDefault: true }; + const dto = { + label: 'HQ', + address: '1 Main St', + city: 'Lagos', + country: 'Nigeria', + isDefault: true, + }; const saved = { id: 'uuid', userId: 'user1', ...dto }; repo.create.mockReturnValue(saved); repo.save.mockResolvedValue(saved); await service.create('user1', dto); - expect(repo.update).toHaveBeenCalledWith({ userId: 'user1' }, { isDefault: false }); + expect(repo.update).toHaveBeenCalledWith( + { userId: 'user1' }, + { isDefault: false }, + ); }); }); @@ -56,19 +70,25 @@ describe('AddressesService', () => { it('returns addresses for user', async () => { repo.find.mockResolvedValue([]); await service.findAll('user1'); - expect(repo.find).toHaveBeenCalledWith(expect.objectContaining({ where: { userId: 'user1' } })); + expect(repo.find).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: 'user1' } }), + ); }); }); describe('findOne', () => { it('throws NotFoundException when not found', async () => { repo.findOne.mockResolvedValue(null); - await expect(service.findOne('id', 'user1')).rejects.toThrow(NotFoundException); + await expect(service.findOne('id', 'user1')).rejects.toThrow( + NotFoundException, + ); }); it('throws ForbiddenException when wrong owner', async () => { repo.findOne.mockResolvedValue({ id: 'id', userId: 'other' }); - await expect(service.findOne('id', 'user1')).rejects.toThrow(ForbiddenException); + await expect(service.findOne('id', 'user1')).rejects.toThrow( + ForbiddenException, + ); }); }); diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 6a7f1b8a..9f6dc977 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -119,7 +119,10 @@ export class AdminController { @Patch('certifications/:id/verify') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Verify or unverify a carrier certification' }) - @ApiResponse({ status: 200, description: 'Certification verification updated' }) + @ApiResponse({ + status: 200, + description: 'Certification verification updated', + }) @ApiResponse({ status: 404, description: 'Certification not found' }) verifyCertification( @Param('id', ParseUUIDPipe) id: string, diff --git a/backend/src/audit-log/audit-log.controller.ts b/backend/src/audit-log/audit-log.controller.ts index 2b4652f0..de2bd0c6 100644 --- a/backend/src/audit-log/audit-log.controller.ts +++ b/backend/src/audit-log/audit-log.controller.ts @@ -15,7 +15,9 @@ export class AuditLogController { constructor(private readonly auditLogService: AuditLogService) {} @Get() - @ApiOperation({ summary: 'Get paginated admin audit logs (filterable by action)' }) + @ApiOperation({ + summary: 'Get paginated admin audit logs (filterable by action)', + }) findAll(@Query() query: QueryAuditLogDto) { return this.auditLogService.findAll(query); } diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 3d4beffc..50c1dbce 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -29,6 +29,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: '', + recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/auth/two-factor.service.ts b/backend/src/auth/two-factor.service.ts index b26e27f3..d501b28e 100644 --- a/backend/src/auth/two-factor.service.ts +++ b/backend/src/auth/two-factor.service.ts @@ -1,12 +1,17 @@ -import { Injectable, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { authenticator } from 'otplib'; +import { generateSecret, verify, generateURI } from 'otplib'; import * as qrcode from 'qrcode'; import * as bcrypt from 'bcrypt'; -import { Redis } from 'ioredis'; // Assuming BE-02 Redis instance wrapper setup +import { Redis } from 'ioredis'; import { User } from '../users/entities/user.entity'; import { TwoFactorRecovery } from '../users/entities/two-factor-recovery.entity'; +import { IsNull } from 'typeorm'; @Injectable() export class TwoFactorService { @@ -19,13 +24,19 @@ export class TwoFactorService { private readonly recoveryRepository: Repository, ) { // Standard workspace constructor assignment for Redis connection - this.redisClient = new Redis(process.env.REDIS_URL || 'redis://localhost:6379'); + this.redisClient = new Redis( + process.env.REDIS_URL || 'redis://localhost:6379', + ); } async initiateSetup(userId: number, email: string) { - const secret = authenticator.generateSecret(); + const secret = generateSecret(); const appName = 'YieldLadder platform'; - const otpauthUrl = authenticator.keyuri(email, appName, secret); + const otpauthUrl = generateURI({ + issuer: appName, + label: email, + secret: secret, + }); const qrCodeDataUrl = await qrcode.toDataURL(otpauthUrl); // Cache the temporary secret safely inside Redis with a strict 10 minute TTL @@ -40,12 +51,16 @@ export class TwoFactorService { const secret = await this.redisClient.get(redisKey); if (!secret) { - throw new BadRequestException('2FA setup session expired. Please regenerate the QR configuration.'); + throw new BadRequestException( + '2FA setup session expired. Please regenerate the QR configuration.', + ); } - const isValid = authenticator.verify({ token: otp, secret }); - if (!isValid) { - throw new BadRequestException('Invalid confirmation code. Verification rejected.'); + const isValid = await verify({ token: otp, secret }); + if (!isValid.valid) { + throw new BadRequestException( + 'Invalid confirmation code. Verification rejected.', + ); } // Persist configuration settings to user entity @@ -63,7 +78,10 @@ export class TwoFactorService { for (let i = 0; i < 8; i++) { // Create readable 8-character structural split code chunks - const plainCode = Math.random().toString(36).substring(2, 10).toUpperCase(); + const plainCode = Math.random() + .toString(36) + .substring(2, 10) + .toUpperCase(); plainRecoveryCodes.push(plainCode); const codeHash = await bcrypt.hash(plainCode, 10); @@ -75,23 +93,34 @@ export class TwoFactorService { return { recoveryCodes: plainRecoveryCodes }; } - async verifyTokenOrRecovery(userId: number, inputToken: string): Promise { - const user = await this.userRepository.createQueryBuilder('user') + async verifyTokenOrRecovery( + userId: number, + inputToken: string, + ): Promise { + const user = await this.userRepository + .createQueryBuilder('user') .addSelect('user.twoFactorSecret') .where('user.id = :userId', { userId }) .getOne(); if (!user || !user.twoFactorSecret) { - throw new UnauthorizedException('Multi-factor authorization is not configured for this account.'); + throw new UnauthorizedException( + 'Multi-factor authorization is not configured for this account.', + ); } // Path A: Validate via standard time-based dynamic OTP first - const isTotpValid = authenticator.verify({ token: inputToken, secret: user.twoFactorSecret }); - if (isTotpValid) return true; + const isTotpValid = await verify({ + token: inputToken, + secret: user.twoFactorSecret, + }); + if (isTotpValid.valid) return true; // Path B: Fall back to un-used emergency recovery tokens - const records = await this.recoveryRepository.find({ where: { userId, usedAt: null } }); - + const records = await this.recoveryRepository.find({ + where: { userId, usedAt: IsNull() }, + }); + for (const record of records) { const match = await bcrypt.compare(inputToken, record.codeHash); if (match) { @@ -106,10 +135,10 @@ export class TwoFactorService { async deactivate(userId: number) { await this.userRepository.update(userId, { - twoFactorSecret: null, + twoFactorSecret: '', isTwoFactorEnabled: false, }); // Wipe matching system recovery database objects safely await this.recoveryRepository.delete({ userId }); } -} \ No newline at end of file +} diff --git a/backend/src/bids/bids-notifications.service.ts b/backend/src/bids/bids-notifications.service.ts index 889fd951..95c12238 100644 --- a/backend/src/bids/bids-notifications.service.ts +++ b/backend/src/bids/bids-notifications.service.ts @@ -25,11 +25,17 @@ export class BidsNotificationsService { private readonly shipmentRepo: Repository, ) {} - private async sendSafe(to: string, subject: string, html: string): Promise { + private async sendSafe( + to: string, + subject: string, + html: string, + ): Promise { try { await this.mailerService.sendMail({ to, subject, html }); } catch (err: unknown) { - this.logger.warn(`Failed to send email to ${to}: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn( + `Failed to send email to ${to}: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -43,7 +49,10 @@ export class BidsNotificationsService { `; } - private async loadRelations(bid: Bid, shipment: Shipment): Promise<{ carrier: User | null; shipper: User | null }> { + private async loadRelations( + bid: Bid, + shipment: Shipment, + ): Promise<{ carrier: User | null; shipper: User | null }> { const fullShipment = await this.shipmentRepo.findOne({ where: { id: shipment.id }, relations: ['shipper'], diff --git a/backend/src/bids/bids.controller.ts b/backend/src/bids/bids.controller.ts index 6ffb618c..69b3101c 100644 --- a/backend/src/bids/bids.controller.ts +++ b/backend/src/bids/bids.controller.ts @@ -62,7 +62,9 @@ export class BidsController { @HttpCode(HttpStatus.OK) @UseGuards(RolesGuard) @Roles(UserRole.SHIPPER) - @ApiOperation({ summary: 'Shipper accepts a bid — assigns carrier, rejects others' }) + @ApiOperation({ + summary: 'Shipper accepts a bid — assigns carrier, rejects others', + }) @ApiParam({ name: 'id', description: 'Shipment ID' }) @ApiParam({ name: 'bidId', description: 'Bid ID' }) acceptBid( diff --git a/backend/src/bids/bids.service.spec.ts b/backend/src/bids/bids.service.spec.ts index 3fb0ad6f..61246c62 100644 --- a/backend/src/bids/bids.service.spec.ts +++ b/backend/src/bids/bids.service.spec.ts @@ -76,7 +76,9 @@ describe('BidsService', () => { bidRepo.create.mockReturnValue(bid); bidRepo.save.mockResolvedValue(bid); - const result = await service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }); + const result = await service.submitBid('ship1', 'carrier1', { + proposedPrice: 100, + }); expect(result).toMatchObject({ id: 'bid1' }); expect(bidRepo.create).toHaveBeenCalledWith( expect.objectContaining({ expiresAt: expect.any(Date) }), @@ -84,21 +86,31 @@ describe('BidsService', () => { }); it('throws if shipment not PENDING', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ status: ShipmentStatus.ACCEPTED })); - await expect(service.submitBid('ship1', 'carrier1', { proposedPrice: 100 })).rejects.toThrow(BadRequestException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ status: ShipmentStatus.ACCEPTED }), + ); + await expect( + service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }), + ).rejects.toThrow(BadRequestException); }); it('throws if carrier already has a pending bid', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); bidRepo.findOne.mockResolvedValue(makeBid()); - await expect(service.submitBid('ship1', 'carrier1', { proposedPrice: 100 })).rejects.toThrow(BadRequestException); + await expect( + service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }), + ).rejects.toThrow(BadRequestException); }); }); describe('getBids', () => { it('throws ForbiddenException if requester is not the shipper', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ shipperId: 'other' })); - await expect(service.getBids('ship1', 'shipper1')).rejects.toThrow(ForbiddenException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ shipperId: 'other' }), + ); + await expect(service.getBids('ship1', 'shipper1')).rejects.toThrow( + ForbiddenException, + ); }); it('returns bids with isExpired for the shipment owner', async () => { @@ -122,25 +134,36 @@ describe('BidsService', () => { const result = await service.acceptBid('ship1', 'bid1', 'shipper1'); expect(result.status).toBe(BidStatus.ACCEPTED); expect(bidRepo.update).toHaveBeenCalled(); - expect(shipmentRepo.update).toHaveBeenCalledWith('ship1', expect.objectContaining({ carrierId: 'carrier1' })); + expect(shipmentRepo.update).toHaveBeenCalledWith( + 'ship1', + expect.objectContaining({ carrierId: 'carrier1' }), + ); }); it('throws BadRequestException for expired bid', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); const expired = makeBid({ expiresAt: new Date(Date.now() - 1000) }); bidRepo.findOne.mockResolvedValue(expired); - await expect(service.acceptBid('ship1', 'bid1', 'shipper1')).rejects.toThrow(BadRequestException); + await expect( + service.acceptBid('ship1', 'bid1', 'shipper1'), + ).rejects.toThrow(BadRequestException); }); it('throws ForbiddenException if not the shipper', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ shipperId: 'other' })); - await expect(service.acceptBid('ship1', 'bid1', 'shipper1')).rejects.toThrow(ForbiddenException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ shipperId: 'other' }), + ); + await expect( + service.acceptBid('ship1', 'bid1', 'shipper1'), + ).rejects.toThrow(ForbiddenException); }); it('throws NotFoundException if bid not found', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); bidRepo.findOne.mockResolvedValue(null); - await expect(service.acceptBid('ship1', 'bid1', 'shipper1')).rejects.toThrow(NotFoundException); + await expect( + service.acceptBid('ship1', 'bid1', 'shipper1'), + ).rejects.toThrow(NotFoundException); }); }); @@ -149,68 +172,110 @@ describe('BidsService', () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); const bid = makeBid(); bidRepo.findOne.mockResolvedValue(bid); - bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + bidRepo.save.mockResolvedValue({ + ...bid, + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); - const result = await service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }); + const result = await service.counterBid('ship1', 'bid1', 'shipper1', { + counterPrice: 90, + }); expect(result.status).toBe(BidStatus.COUNTER_OFFERED); - expect(eventEmitter.emit).toHaveBeenCalledWith('bid.countered', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'bid.countered', + expect.anything(), + ); }); it('throws if bid is expired', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ expiresAt: new Date(Date.now() - 1000) })); - await expect(service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 })).rejects.toThrow(BadRequestException); + bidRepo.findOne.mockResolvedValue( + makeBid({ expiresAt: new Date(Date.now() - 1000) }), + ); + await expect( + service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }), + ).rejects.toThrow(BadRequestException); }); it('throws ForbiddenException if not shipper', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ shipperId: 'other' })); - await expect(service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 })).rejects.toThrow(ForbiddenException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ shipperId: 'other' }), + ); + await expect( + service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }), + ).rejects.toThrow(ForbiddenException); }); }); describe('acceptCounter', () => { it('accepts the counter, assigns carrier, emits shipment.accepted', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - const bid = makeBid({ status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + const bid = makeBid({ + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); bidRepo.findOne.mockResolvedValue(bid); - bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.COUNTER_ACCEPTED }); + bidRepo.save.mockResolvedValue({ + ...bid, + status: BidStatus.COUNTER_ACCEPTED, + }); bidRepo.update.mockResolvedValue(undefined); shipmentRepo.update.mockResolvedValue(undefined); const result = await service.acceptCounter('ship1', 'bid1', 'carrier1'); expect(result.status).toBe(BidStatus.COUNTER_ACCEPTED); - expect(eventEmitter.emit).toHaveBeenCalledWith('shipment.accepted', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'shipment.accepted', + expect.anything(), + ); }); it('throws ForbiddenException if not the bid owner', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.COUNTER_OFFERED })); - await expect(service.acceptCounter('ship1', 'bid1', 'other-carrier')).rejects.toThrow(ForbiddenException); + bidRepo.findOne.mockResolvedValue( + makeBid({ status: BidStatus.COUNTER_OFFERED }), + ); + await expect( + service.acceptCounter('ship1', 'bid1', 'other-carrier'), + ).rejects.toThrow(ForbiddenException); }); it('throws BadRequestException if not in COUNTER_OFFERED status', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.PENDING })); - await expect(service.acceptCounter('ship1', 'bid1', 'carrier1')).rejects.toThrow(BadRequestException); + await expect( + service.acceptCounter('ship1', 'bid1', 'carrier1'), + ).rejects.toThrow(BadRequestException); }); }); describe('declineCounter', () => { it('sets bid to REJECTED and emits event', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - const bid = makeBid({ status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + const bid = makeBid({ + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); bidRepo.findOne.mockResolvedValue(bid); bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.REJECTED }); const result = await service.declineCounter('ship1', 'bid1', 'carrier1'); expect(result.status).toBe(BidStatus.REJECTED); - expect(eventEmitter.emit).toHaveBeenCalledWith('bid.counter_declined', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'bid.counter_declined', + expect.anything(), + ); }); it('throws ForbiddenException if not the bid owner', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.COUNTER_OFFERED })); - await expect(service.declineCounter('ship1', 'bid1', 'other')).rejects.toThrow(ForbiddenException); + bidRepo.findOne.mockResolvedValue( + makeBid({ status: BidStatus.COUNTER_OFFERED }), + ); + await expect( + service.declineCounter('ship1', 'bid1', 'other'), + ).rejects.toThrow(ForbiddenException); }); }); }); diff --git a/backend/src/bids/bids.service.ts b/backend/src/bids/bids.service.ts index 63da1f3f..b647c93f 100644 --- a/backend/src/bids/bids.service.ts +++ b/backend/src/bids/bids.service.ts @@ -41,12 +41,16 @@ export class BidsService { where: { id: shipmentId }, relations: ['shipper'], }); - if (!shipment) throw new NotFoundException(`Shipment ${shipmentId} not found`); + if (!shipment) + throw new NotFoundException(`Shipment ${shipmentId} not found`); return shipment; } private async getBidOrFail(bidId: string, shipmentId: string): Promise { - const bid = await this.bidRepo.findOne({ where: { id: bidId, shipmentId }, relations: ['carrier'] }); + const bid = await this.bidRepo.findOne({ + where: { id: bidId, shipmentId }, + relations: ['carrier'], + }); if (!bid) throw new NotFoundException(`Bid ${bidId} not found`); return bid; } @@ -67,14 +71,18 @@ export class BidsService { ): Promise { const shipment = await this.getShipment(shipmentId); if (shipment.status !== ShipmentStatus.PENDING) { - throw new BadRequestException('Bids can only be placed on PENDING shipments'); + throw new BadRequestException( + 'Bids can only be placed on PENDING shipments', + ); } const existing = await this.bidRepo.findOne({ where: { shipmentId, carrierId, status: BidStatus.PENDING }, }); if (existing) { - throw new BadRequestException('You already have a pending bid on this shipment'); + throw new BadRequestException( + 'You already have a pending bid on this shipment', + ); } const expiresAt = new Date(); @@ -107,7 +115,10 @@ export class BidsService { return this.addIsExpired(saved); } - async getBids(shipmentId: string, requesterId: string): Promise { + async getBids( + shipmentId: string, + requesterId: string, + ): Promise { const shipment = await this.getShipment(shipmentId); if (shipment.shipperId !== requesterId) { throw new ForbiddenException('Only the shipment owner can view bids'); @@ -117,7 +128,7 @@ export class BidsService { relations: ['carrier'], order: { proposedPrice: 'ASC' }, }); - return bids.map(b => this.addIsExpired(b)); + return bids.map((b) => this.addIsExpired(b)); } async acceptBid( @@ -138,7 +149,9 @@ export class BidsService { throw new BadRequestException('Bid is no longer pending'); } if (this.isBidExpired(bid)) { - throw new BadRequestException('This bid has expired and cannot be accepted'); + throw new BadRequestException( + 'This bid has expired and cannot be accepted', + ); } bid.status = BidStatus.ACCEPTED; @@ -186,7 +199,9 @@ export class BidsService { ): Promise { const shipment = await this.getShipment(shipmentId); if (shipment.shipperId !== requesterId) { - throw new ForbiddenException('Only the shipment owner can make a counteroffer'); + throw new ForbiddenException( + 'Only the shipment owner can make a counteroffer', + ); } const bid = await this.getBidOrFail(bidId, shipmentId); @@ -194,7 +209,9 @@ export class BidsService { throw new BadRequestException('Can only counter a PENDING bid'); } if (this.isBidExpired(bid)) { - throw new BadRequestException('This bid has expired and cannot be countered'); + throw new BadRequestException( + 'This bid has expired and cannot be countered', + ); } bid.counterPrice = dto.counterPrice; @@ -237,7 +254,10 @@ export class BidsService { status: ShipmentStatus.ACCEPTED, }); - this.eventEmitter.emit('shipment.accepted', { shipment, actorId: requesterId }); + this.eventEmitter.emit('shipment.accepted', { + shipment, + actorId: requesterId, + }); return bid; } @@ -251,7 +271,9 @@ export class BidsService { const shipment = await this.getShipment(shipmentId); if (bid.carrierId !== requesterId) { - throw new ForbiddenException('Only the bid owner can decline the counter'); + throw new ForbiddenException( + 'Only the bid owner can decline the counter', + ); } if (bid.status !== BidStatus.COUNTER_OFFERED) { throw new BadRequestException('Bid is not in COUNTER_OFFERED status'); diff --git a/backend/src/bids/entities/bid.entity.ts b/backend/src/bids/entities/bid.entity.ts index c85467f9..182488c1 100644 --- a/backend/src/bids/entities/bid.entity.ts +++ b/backend/src/bids/entities/bid.entity.ts @@ -26,7 +26,11 @@ export class Bid { id: string; @Index() - @ManyToOne(() => Shipment, { eager: false, nullable: false, onDelete: 'CASCADE' }) + @ManyToOne(() => Shipment, { + eager: false, + nullable: false, + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'shipment_id' }) shipment: Shipment; @@ -49,7 +53,13 @@ export class Bid { @Column({ type: 'enum', enum: BidStatus, default: BidStatus.PENDING }) status: BidStatus; - @Column({ name: 'counter_price', type: 'decimal', precision: 14, scale: 2, nullable: true }) + @Column({ + name: 'counter_price', + type: 'decimal', + precision: 14, + scale: 2, + nullable: true, + }) counterPrice: number | null; @Column({ name: 'counter_message', type: 'text', nullable: true }) diff --git a/backend/src/carriers/carrier-certifications.service.spec.ts b/backend/src/carriers/carrier-certifications.service.spec.ts index 8db52e36..2ede5bc6 100644 --- a/backend/src/carriers/carrier-certifications.service.spec.ts +++ b/backend/src/carriers/carrier-certifications.service.spec.ts @@ -2,7 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { CarrierCertificationsService } from './carrier-certifications.service'; -import { CarrierCertification, CertificationType } from './entities/carrier-certification.entity'; +import { + CarrierCertification, + CertificationType, +} from './entities/carrier-certification.entity'; import { CreateCarrierCertificationDto, UpdateCertificationVerificationDto, diff --git a/backend/src/carriers/carrier-earnings.service.ts b/backend/src/carriers/carrier-earnings.service.ts index 7f0db2e3..4e7715a4 100644 --- a/backend/src/carriers/carrier-earnings.service.ts +++ b/backend/src/carriers/carrier-earnings.service.ts @@ -29,7 +29,10 @@ export class CarrierEarningsService { select: ['id', 'price', 'actualDeliveryDate'], }); - const lifetimeEarnings = completed.reduce((sum, s) => sum + Number(s.price), 0); + const lifetimeEarnings = completed.reduce( + (sum, s) => sum + Number(s.price), + 0, + ); const now = new Date(); const currentMonthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; @@ -54,9 +57,13 @@ export class CarrierEarningsService { } } - const monthlyBreakdown: MonthlyBreakdown[] = Array.from(buckets.entries()).map( - ([month, { earnings, count }]) => ({ month, earnings, completedShipments: count }), - ); + const monthlyBreakdown: MonthlyBreakdown[] = Array.from( + buckets.entries(), + ).map(([month, { earnings, count }]) => ({ + month, + earnings, + completedShipments: count, + })); const currentMonthEarnings = buckets.get(currentMonthKey)?.earnings ?? 0; diff --git a/backend/src/carriers/carriers.controller.ts b/backend/src/carriers/carriers.controller.ts index 28231042..71fd11de 100644 --- a/backend/src/carriers/carriers.controller.ts +++ b/backend/src/carriers/carriers.controller.ts @@ -30,7 +30,9 @@ export class CarriersController { @Get('me/metrics') @UseGuards(RolesGuard) @Roles(UserRole.CARRIER) - @ApiOperation({ summary: 'Get performance metrics for the authenticated carrier' }) + @ApiOperation({ + summary: 'Get performance metrics for the authenticated carrier', + }) getMyMetrics(@CurrentUser() user: User) { return this.carriersService.getMyMetrics(user.id); } @@ -58,7 +60,10 @@ export class CarriersController { @Get(':id/certifications') @UseGuards(RolesGuard) - @ApiOperation({ summary: 'Get certifications for a carrier (visible to all authenticated users)' }) + @ApiOperation({ + summary: + 'Get certifications for a carrier (visible to all authenticated users)', + }) getCarrierCertifications(@Param('id', ParseUUIDPipe) id: string) { return this.certificationsService.findByCarrierId(id); } diff --git a/backend/src/carriers/carriers.service.ts b/backend/src/carriers/carriers.service.ts index 8bdcf795..b5882310 100644 --- a/backend/src/carriers/carriers.service.ts +++ b/backend/src/carriers/carriers.service.ts @@ -14,14 +14,27 @@ export class CarriersService { async getMyMetrics(carrierId: string) { const shipments = await this.shipmentRepo.find({ where: { carrierId }, - select: ['id', 'status', 'price', 'currency', 'estimatedDeliveryDate', 'actualDeliveryDate'], + select: [ + 'id', + 'status', + 'price', + 'currency', + 'estimatedDeliveryDate', + 'actualDeliveryDate', + ], }); - const completed = shipments.filter((s) => s.status === ShipmentStatus.COMPLETED); + const completed = shipments.filter( + (s) => s.status === ShipmentStatus.COMPLETED, + ); const delivered = shipments.filter( - (s) => s.status === ShipmentStatus.DELIVERED || s.status === ShipmentStatus.COMPLETED, + (s) => + s.status === ShipmentStatus.DELIVERED || + s.status === ShipmentStatus.COMPLETED, + ); + const cancelled = shipments.filter( + (s) => s.status === ShipmentStatus.CANCELLED, ); - const cancelled = shipments.filter((s) => s.status === ShipmentStatus.CANCELLED); const totalAccepted = shipments.filter( (s) => s.status !== ShipmentStatus.PENDING, @@ -34,11 +47,16 @@ export class CarriersService { new Date(s.actualDeliveryDate) <= new Date(s.estimatedDeliveryDate), ).length; - const onTimeRate = delivered.length > 0 ? onTimeDeliveries / delivered.length : 0; + const onTimeRate = + delivered.length > 0 ? onTimeDeliveries / delivered.length : 0; - const totalEarnings = completed.reduce((sum, s) => sum + Number(s.price), 0); + const totalEarnings = completed.reduce( + (sum, s) => sum + Number(s.price), + 0, + ); - const cancellationRate = totalAccepted > 0 ? cancelled.length / totalAccepted : 0; + const cancellationRate = + totalAccepted > 0 ? cancelled.length / totalAccepted : 0; return { totalAccepted, diff --git a/backend/src/cloudinary/cloudinary.service.ts b/backend/src/cloudinary/cloudinary.service.ts index 44adeb30..baca8387 100644 --- a/backend/src/cloudinary/cloudinary.service.ts +++ b/backend/src/cloudinary/cloudinary.service.ts @@ -21,7 +21,10 @@ export class CloudinaryService { const stream = cloudinary.uploader.upload_stream( { folder, public_id: publicId, resource_type: 'auto' }, (error: unknown, result: UploadApiResponse) => { - if (error) return reject(error); + if (error) + return reject( + error instanceof Error ? error : new Error(JSON.stringify(error)), + ); resolve(result); }, ); diff --git a/backend/src/common/cursor-pagination.util.ts b/backend/src/common/cursor-pagination.util.ts index 964522d3..13213ed9 100644 --- a/backend/src/common/cursor-pagination.util.ts +++ b/backend/src/common/cursor-pagination.util.ts @@ -1,7 +1,7 @@ import { Repository, FindOptionsWhere, LessThan } from 'typeorm'; export interface CursorPageOptions { - cursor?: string; // opaque base64 cursor from previous response + cursor?: string; // opaque base64 cursor from previous response limit?: number; // Legacy offset fallback page?: number; @@ -26,7 +26,9 @@ export function encodeCursor(createdAt: Date, id: string): string { export function decodeCursor(cursor: string): CursorPayload { try { - return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as CursorPayload; + return JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as CursorPayload; } catch { throw new Error('Invalid pagination cursor'); } @@ -40,7 +42,10 @@ export async function cursorPaginate( const limit = Math.min(options.limit ?? 20, 100); // Offset fallback for backwards compatibility - if (!options.cursor && (options.page !== undefined || options.pageSize !== undefined)) { + if ( + !options.cursor && + (options.page !== undefined || options.pageSize !== undefined) + ) { const page = options.page ?? 1; const pageSize = options.pageSize ?? limit; const [data, total] = await repo.findAndCount({ diff --git a/backend/src/documents/documents.service.ts b/backend/src/documents/documents.service.ts index 5a6ccc61..909e4d98 100644 --- a/backend/src/documents/documents.service.ts +++ b/backend/src/documents/documents.service.ts @@ -94,17 +94,12 @@ export class DocumentsService { const saved = await this.documentRepo.save(doc); - this.enqueueIpfsPin(saved.id, saved.storedUrl!).catch(() => { - // Silently ignore — job will be retried or handled by worker - }); + this.enqueueIpfsPin(saved.id, saved.storedUrl!); return saved; } - private async enqueueIpfsPin( - documentId: string, - cloudinaryUrl: string, - ): Promise { + private enqueueIpfsPin(documentId: string, cloudinaryUrl: string): void { // TODO: Replace with BullMQ queue.add('ipfs-pin', { documentId, cloudinaryUrl }) console.log( `[IPFS Pin] Enqueued: documentId=${documentId}, url=${cloudinaryUrl}`, diff --git a/backend/src/messaging/dto/create-conversation.dto.ts b/backend/src/messaging/dto/create-conversation.dto.ts index efbebd0e..2712b937 100644 --- a/backend/src/messaging/dto/create-conversation.dto.ts +++ b/backend/src/messaging/dto/create-conversation.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsUUID } from 'class-validator'; +import { IsNotEmpty, IsUUID } from 'class-validator'; export class CreateConversationDto { @IsUUID() diff --git a/backend/src/messaging/messaging.controller.ts b/backend/src/messaging/messaging.controller.ts index 90371a0a..73816e15 100644 --- a/backend/src/messaging/messaging.controller.ts +++ b/backend/src/messaging/messaging.controller.ts @@ -29,10 +29,7 @@ export class MessagingController { } @Post() - findOrCreate( - @Body() dto: CreateConversationDto, - @CurrentUser() user: User, - ) { + findOrCreate(@Body() dto: CreateConversationDto, @CurrentUser() user: User) { return this.messagingService.findOrCreateConversation(dto, user); } diff --git a/backend/src/messaging/messaging.service.ts b/backend/src/messaging/messaging.service.ts index 0e70593a..1326d004 100644 --- a/backend/src/messaging/messaging.service.ts +++ b/backend/src/messaging/messaging.service.ts @@ -84,10 +84,7 @@ export class MessagingService { }[] > { const conversations = await this.conversationRepo.find({ - where: [ - { shipperId: currentUser.id }, - { carrierId: currentUser.id }, - ], + where: [{ shipperId: currentUser.id }, { carrierId: currentUser.id }], order: { lastMessageAt: 'DESC' }, }); @@ -112,9 +109,7 @@ export class MessagingService { return { id: conv.id, shipmentId: conv.shipmentId, - lastMessage: lastMsg - ? lastMsg.body.substring(0, 80) - : null, + lastMessage: lastMsg ? lastMsg.body.substring(0, 80) : null, lastMessageAt: conv.lastMessageAt, unreadCount, }; diff --git a/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts b/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts index c1a35c9f..4c0698cc 100644 --- a/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts +++ b/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts @@ -1,24 +1,42 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class BidCounterOfferAndExpiry1750678212610 implements MigrationInterface { +export class BidCounterOfferAndExpiry1750678212610 + implements MigrationInterface +{ name = 'BidCounterOfferAndExpiry1750678212610'; public async up(queryRunner: QueryRunner): Promise { // Extend the BidStatus enum with new values - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_OFFERED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_ACCEPTED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_REJECTED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'EXPIRED'`); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_OFFERED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_ACCEPTED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_REJECTED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'EXPIRED'`, + ); // Add new columns - await queryRunner.query(`ALTER TABLE "bids" ADD "counter_price" numeric(14,2)`); + await queryRunner.query( + `ALTER TABLE "bids" ADD "counter_price" numeric(14,2)`, + ); await queryRunner.query(`ALTER TABLE "bids" ADD "counter_message" text`); - await queryRunner.query(`ALTER TABLE "bids" ADD "expires_at" TIMESTAMP WITH TIME ZONE`); - await queryRunner.query(`ALTER TABLE "bids" ADD "counter_offered_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query( + `ALTER TABLE "bids" ADD "expires_at" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "bids" ADD "counter_offered_at" TIMESTAMP WITH TIME ZONE`, + ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_offered_at"`); + await queryRunner.query( + `ALTER TABLE "bids" DROP COLUMN "counter_offered_at"`, + ); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "expires_at"`); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_message"`); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_price"`); diff --git a/backend/src/notification-preferences/notification-preferences.controller.ts b/backend/src/notification-preferences/notification-preferences.controller.ts index 1e81bad8..69c30725 100644 --- a/backend/src/notification-preferences/notification-preferences.controller.ts +++ b/backend/src/notification-preferences/notification-preferences.controller.ts @@ -12,14 +12,19 @@ export class NotificationPreferencesController { constructor(private readonly prefsService: NotificationPreferencesService) {} @Get() - @ApiOperation({ summary: 'Get notification preferences for the current user' }) + @ApiOperation({ + summary: 'Get notification preferences for the current user', + }) get(@CurrentUser() user: User) { return this.prefsService.getOrCreate(user.id); } @Patch() @ApiOperation({ summary: 'Update notification preferences' }) - update(@CurrentUser() user: User, @Body() dto: UpdateNotificationPreferencesDto) { + update( + @CurrentUser() user: User, + @Body() dto: UpdateNotificationPreferencesDto, + ) { return this.prefsService.update(user.id, dto); } } diff --git a/backend/src/notification-preferences/notification-preferences.service.spec.ts b/backend/src/notification-preferences/notification-preferences.service.spec.ts index 760b8044..c0a2e0d0 100644 --- a/backend/src/notification-preferences/notification-preferences.service.spec.ts +++ b/backend/src/notification-preferences/notification-preferences.service.spec.ts @@ -79,7 +79,10 @@ describe('NotificationPreferencesService', () => { }); it('returns false for disabled preference', async () => { - repo.findOne.mockResolvedValue({ ...defaultPrefs(), shipmentDisputed: false }); + repo.findOne.mockResolvedValue({ + ...defaultPrefs(), + shipmentDisputed: false, + }); const result = await service.isEnabled('user1', 'shipmentDisputed'); expect(result).toBe(false); }); diff --git a/backend/src/notification-preferences/notification-preferences.service.ts b/backend/src/notification-preferences/notification-preferences.service.ts index c40312d9..08212f25 100644 --- a/backend/src/notification-preferences/notification-preferences.service.ts +++ b/backend/src/notification-preferences/notification-preferences.service.ts @@ -31,9 +31,12 @@ export class NotificationPreferencesService { async isEnabled( userId: string, - key: keyof Omit, + key: keyof Omit< + NotificationPreferences, + 'id' | 'userId' | 'user' | 'updatedAt' + >, ): Promise { const prefs = await this.getOrCreate(userId); - return prefs[key] as boolean; + return prefs[key]; } } diff --git a/backend/src/notifications/dto/notification-query.dto.ts b/backend/src/notifications/dto/notification-query.dto.ts index d8dece7d..84a3f48a 100644 --- a/backend/src/notifications/dto/notification-query.dto.ts +++ b/backend/src/notifications/dto/notification-query.dto.ts @@ -1,4 +1,10 @@ -import { IsOptional, IsEnum, IsPositive, IsBoolean, Max } from 'class-validator'; +import { + IsOptional, + IsEnum, + IsPositive, + IsBoolean, + Max, +} from 'class-validator'; import { Type, Transform } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { NotificationType } from '../entities/notification.entity'; @@ -27,4 +33,4 @@ export class NotificationQueryDto { @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() isRead?: boolean; -} \ No newline at end of file +} diff --git a/backend/src/notifications/entities/notification.entity.ts b/backend/src/notifications/entities/notification.entity.ts index a2b78851..0ca3d3a5 100644 --- a/backend/src/notifications/entities/notification.entity.ts +++ b/backend/src/notifications/entities/notification.entity.ts @@ -35,7 +35,11 @@ export class Notification { @JoinColumn({ name: 'userId' }) user: User; - @Column({ type: 'enum', enum: NotificationType, default: NotificationType.GENERAL }) + @Column({ + type: 'enum', + enum: NotificationType, + default: NotificationType.GENERAL, + }) type: NotificationType; @Column({ type: 'varchar', length: 255 }) @@ -52,4 +56,4 @@ export class Notification { @CreateDateColumn({ name: 'created_at' }) createdAt: Date; -} \ No newline at end of file +} diff --git a/backend/src/notifications/notification-inbox.controller.ts b/backend/src/notifications/notification-inbox.controller.ts index 9e04ecb6..ff70e488 100644 --- a/backend/src/notifications/notification-inbox.controller.ts +++ b/backend/src/notifications/notification-inbox.controller.ts @@ -25,8 +25,16 @@ export class NotificationInboxController { @Get() @ApiOperation({ summary: 'Fetch my notification inbox (paginated)' }) - async getInbox(@CurrentUser() user: User, @Query() query: NotificationQueryDto) { - return this.inboxService.findAll(user.id, query.page, query.limit, query.isRead); + async getInbox( + @CurrentUser() user: User, + @Query() query: NotificationQueryDto, + ) { + return this.inboxService.findAll( + user.id, + query.page, + query.limit, + query.isRead, + ); } @Get('unread-count') @@ -54,4 +62,4 @@ export class NotificationInboxController { await this.inboxService.markAllRead(user.id); return { message: 'All notifications marked as read' }; } -} \ No newline at end of file +} diff --git a/backend/src/notifications/notification-inbox.service.ts b/backend/src/notifications/notification-inbox.service.ts index 3c5b4d30..0ca9a9fd 100644 --- a/backend/src/notifications/notification-inbox.service.ts +++ b/backend/src/notifications/notification-inbox.service.ts @@ -33,7 +33,14 @@ export class NotificationInboxService { take: limit, }); const unread = await this.repo.count({ where: { userId, isRead: false } }); - return { items, total, unread, page, limit, totalPages: Math.ceil(total / limit) }; + return { + items, + total, + unread, + page, + limit, + totalPages: Math.ceil(total / limit), + }; } async markRead(id: string, userId: string): Promise { @@ -50,4 +57,4 @@ export class NotificationInboxService { async unreadCount(userId: string): Promise { return this.repo.count({ where: { userId, isRead: false } }); } -} \ No newline at end of file +} diff --git a/backend/src/notifications/notifications.gateway.ts b/backend/src/notifications/notifications.gateway.ts index 207746c9..cab1ed93 100644 --- a/backend/src/notifications/notifications.gateway.ts +++ b/backend/src/notifications/notifications.gateway.ts @@ -21,10 +21,7 @@ import { SHIPMENT_CREATED, ShipmentEvent, } from '../shipments/events/shipment.events'; -import { - MESSAGE_NEW, - MessageNewEvent, -} from '../messaging/messaging.service'; +import { MESSAGE_NEW, MessageNewEvent } from '../messaging/messaging.service'; import { JwtPayload } from '../auth/strategies/jwt.strategy'; /** Room prefix for per-user rooms */ diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts index f2532896..209b14c0 100644 --- a/backend/src/notifications/notifications.module.ts +++ b/backend/src/notifications/notifications.module.ts @@ -24,7 +24,11 @@ import { Notification } from './entities/notification.entity'; }), ], controllers: [NotificationInboxController], - providers: [NotificationsService, NotificationsGateway, NotificationInboxService], + providers: [ + NotificationsService, + NotificationsGateway, + NotificationInboxService, + ], exports: [NotificationInboxService], }) -export class NotificationsModule {} \ No newline at end of file +export class NotificationsModule {} diff --git a/backend/src/push/push.module.ts b/backend/src/push/push.module.ts index 2eb7dce5..7707a924 100644 --- a/backend/src/push/push.module.ts +++ b/backend/src/push/push.module.ts @@ -7,4 +7,4 @@ import { PushService } from './push.service'; providers: [PushService], exports: [PushService], }) -export class PushModule {} \ No newline at end of file +export class PushModule {} diff --git a/backend/src/push/push.service.ts b/backend/src/push/push.service.ts index 6b76b01e..dd866ff6 100644 --- a/backend/src/push/push.service.ts +++ b/backend/src/push/push.service.ts @@ -29,4 +29,4 @@ export class PushService { this.logger.warn('Failed to send push notification: ' + msg); } } -} \ No newline at end of file +} diff --git a/backend/src/reviews/reviews.controller.ts b/backend/src/reviews/reviews.controller.ts index 5ab730e3..43a50419 100644 --- a/backend/src/reviews/reviews.controller.ts +++ b/backend/src/reviews/reviews.controller.ts @@ -1,4 +1,11 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ReviewsService } from './reviews.service'; import { CreateReviewDto } from './dto/create-review.dto'; @@ -34,4 +41,3 @@ export class UserRatingController { return this.reviewsService.getAverageRating(id); } } - diff --git a/backend/src/reviews/reviews.service.spec.ts b/backend/src/reviews/reviews.service.spec.ts index c891a0df..01421bb3 100644 --- a/backend/src/reviews/reviews.service.spec.ts +++ b/backend/src/reviews/reviews.service.spec.ts @@ -1,6 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, +} from '@nestjs/common'; import { ReviewsService } from './reviews.service'; import { Review } from './entities/review.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; @@ -26,6 +30,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: null as unknown as string, + recoveryCodes: [], ...overrides, }; } @@ -61,7 +68,12 @@ function makeShipment(overrides: Partial = {}): Shipment { describe('ReviewsService', () => { let service: ReviewsService; - let reviewRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock; createQueryBuilder: jest.Mock }; + let reviewRepo: { + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + createQueryBuilder: jest.Mock; + }; let shipmentRepo: { findOne: jest.Mock }; beforeEach(async () => { @@ -99,28 +111,40 @@ describe('ReviewsService', () => { expect(result).toBe(review); expect(reviewRepo.create).toHaveBeenCalledWith( - expect.objectContaining({ reviewerId: 'shipper-1', revieweeId: 'carrier-1', rating: 5 }), + expect.objectContaining({ + reviewerId: 'shipper-1', + revieweeId: 'carrier-1', + rating: 5, + }), ); }); it('throws BadRequestException if shipment not COMPLETED', async () => { - shipmentRepo.findOne.mockResolvedValue(makeShipment({ status: ShipmentStatus.DELIVERED })); + shipmentRepo.findOne.mockResolvedValue( + makeShipment({ status: ShipmentStatus.DELIVERED }), + ); - await expect(service.create('ship-1', makeUser(), { rating: 4 })).rejects.toThrow(BadRequestException); + await expect( + service.create('ship-1', makeUser(), { rating: 4 }), + ).rejects.toThrow(BadRequestException); }); it('throws ForbiddenException if reviewer is not a party', async () => { shipmentRepo.findOne.mockResolvedValue(makeShipment()); const outsider = makeUser({ id: 'outsider-1' }); - await expect(service.create('ship-1', outsider, { rating: 3 })).rejects.toThrow(ForbiddenException); + await expect( + service.create('ship-1', outsider, { rating: 3 }), + ).rejects.toThrow(ForbiddenException); }); it('throws ConflictException on duplicate review', async () => { shipmentRepo.findOne.mockResolvedValue(makeShipment()); reviewRepo.findOne.mockResolvedValue({ id: 'existing-review' }); - await expect(service.create('ship-1', makeUser(), { rating: 5 })).rejects.toThrow(ConflictException); + await expect( + service.create('ship-1', makeUser(), { rating: 5 }), + ).rejects.toThrow(ConflictException); }); }); diff --git a/backend/src/reviews/reviews.service.ts b/backend/src/reviews/reviews.service.ts index cc1d5bac..b1435a1b 100644 --- a/backend/src/reviews/reviews.service.ts +++ b/backend/src/reviews/reviews.service.ts @@ -21,24 +21,35 @@ export class ReviewsService { private readonly shipmentRepo: Repository, ) {} - async create(shipmentId: string, reviewer: User, dto: CreateReviewDto): Promise { - const shipment = await this.shipmentRepo.findOne({ where: { id: shipmentId } }); + async create( + shipmentId: string, + reviewer: User, + dto: CreateReviewDto, + ): Promise { + const shipment = await this.shipmentRepo.findOne({ + where: { id: shipmentId }, + }); if (!shipment) throw new BadRequestException('Shipment not found'); if (shipment.status !== ShipmentStatus.COMPLETED) { - throw new BadRequestException('Reviews can only be left for completed shipments'); + throw new BadRequestException( + 'Reviews can only be left for completed shipments', + ); } const isShipper = shipment.shipperId === reviewer.id; const isCarrier = shipment.carrierId === reviewer.id; if (!isShipper && !isCarrier) { - throw new ForbiddenException('Only parties to the shipment can leave a review'); + throw new ForbiddenException( + 'Only parties to the shipment can leave a review', + ); } const existing = await this.reviewRepo.findOne({ where: { shipmentId, reviewerId: reviewer.id }, }); - if (existing) throw new ConflictException('You have already reviewed this shipment'); + if (existing) + throw new ConflictException('You have already reviewed this shipment'); // Shipper reviews carrier, carrier reviews shipper const revieweeId = isShipper ? shipment.carrierId! : shipment.shipperId; @@ -54,7 +65,9 @@ export class ReviewsService { return this.reviewRepo.save(review); } - async getAverageRating(userId: string): Promise<{ averageRating: number; totalReviews: number }> { + async getAverageRating( + userId: string, + ): Promise<{ averageRating: number; totalReviews: number }> { const result = await this.reviewRepo .createQueryBuilder('r') .where('r.reviewee_id = :userId', { userId }) @@ -63,7 +76,9 @@ export class ReviewsService { .getRawOne<{ avg: string | null; count: string }>(); return { - averageRating: result?.avg ? Math.round(Number(result.avg) * 100) / 100 : 0, + averageRating: result?.avg + ? Math.round(Number(result.avg) * 100) / 100 + : 0, totalReviews: Number(result?.count ?? 0), }; } diff --git a/backend/src/shipments/cancellation-fee.service.ts b/backend/src/shipments/cancellation-fee.service.ts index da95984d..c6069f95 100644 --- a/backend/src/shipments/cancellation-fee.service.ts +++ b/backend/src/shipments/cancellation-fee.service.ts @@ -32,18 +32,28 @@ export class CancellationFeeService { calculateFee(price: number, status: ShipmentStatus): number { const rate = FEE_TIERS[status] ?? null; - if (rate === null) throw new BadRequestException(`Shipment in status "${status}" cannot be cancelled`); + if (rate === null) + throw new BadRequestException( + `Shipment in status "${status}" cannot be cancelled`, + ); return Math.round(price * rate * 100) / 100; } async cancelShipment(shipmentId: string): Promise { - const shipment = await this.shipmentRepo.findOneOrFail({ where: { id: shipmentId } }); + const shipment = await this.shipmentRepo.findOneOrFail({ + where: { id: shipmentId }, + }); if (!CANCELLABLE.has(shipment.status)) { - throw new BadRequestException(`Cannot cancel a shipment with status "${shipment.status}"`); + throw new BadRequestException( + `Cannot cancel a shipment with status "${shipment.status}"`, + ); } - const cancellationFee = this.calculateFee(Number(shipment.price), shipment.status); + const cancellationFee = this.calculateFee( + Number(shipment.price), + shipment.status, + ); await this.shipmentRepo.update(shipmentId, { status: ShipmentStatus.CANCELLED, diff --git a/backend/src/shipments/dispute-evidence.service.ts b/backend/src/shipments/dispute-evidence.service.ts index 21d11f9b..1b4ed91e 100644 --- a/backend/src/shipments/dispute-evidence.service.ts +++ b/backend/src/shipments/dispute-evidence.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; export interface DisputeEvidence { id: string; @@ -38,7 +42,11 @@ export class DisputeEvidenceService { } } - submit(shipmentId: string, userId: string, dto: SubmitEvidenceDto): DisputeEvidence { + submit( + shipmentId: string, + userId: string, + dto: SubmitEvidenceDto, + ): DisputeEvidence { const shipment = this.getShipment(shipmentId); this.assertAccess(shipment, userId); @@ -56,7 +64,11 @@ export class DisputeEvidenceService { return record; } - findAll(shipmentId: string, userId: string, isAdmin: boolean): DisputeEvidence[] { + findAll( + shipmentId: string, + userId: string, + isAdmin: boolean, + ): DisputeEvidence[] { const shipment = this.getShipment(shipmentId); if (!isAdmin) this.assertAccess(shipment, userId); return this.evidence.get(shipmentId) ?? []; diff --git a/backend/src/shipments/dto/analytics-query.dto.ts b/backend/src/shipments/dto/analytics-query.dto.ts index 65f5e9b5..60e85765 100644 --- a/backend/src/shipments/dto/analytics-query.dto.ts +++ b/backend/src/shipments/dto/analytics-query.dto.ts @@ -2,12 +2,18 @@ import { IsOptional, IsDateString } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; export class AnalyticsQueryDto { - @ApiPropertyOptional({ description: 'Start date (ISO 8601)', example: '2024-01-01' }) + @ApiPropertyOptional({ + description: 'Start date (ISO 8601)', + example: '2024-01-01', + }) @IsOptional() @IsDateString() from?: string; - @ApiPropertyOptional({ description: 'End date (ISO 8601)', example: '2024-12-31' }) + @ApiPropertyOptional({ + description: 'End date (ISO 8601)', + example: '2024-12-31', + }) @IsOptional() @IsDateString() to?: string; diff --git a/backend/src/shipments/dto/batch-create-shipments.dto.ts b/backend/src/shipments/dto/batch-create-shipments.dto.ts index e31ae1ba..cd9fa9ea 100644 --- a/backend/src/shipments/dto/batch-create-shipments.dto.ts +++ b/backend/src/shipments/dto/batch-create-shipments.dto.ts @@ -1,4 +1,9 @@ -import { IsArray, ArrayMinSize, ArrayMaxSize, ValidateNested } from 'class-validator'; +import { + IsArray, + ArrayMinSize, + ArrayMaxSize, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { CreateShipmentDto } from './create-shipment.dto'; diff --git a/backend/src/shipments/dto/create-shipment.dto.ts b/backend/src/shipments/dto/create-shipment.dto.ts index 5016a1a5..a808e482 100644 --- a/backend/src/shipments/dto/create-shipment.dto.ts +++ b/backend/src/shipments/dto/create-shipment.dto.ts @@ -36,7 +36,10 @@ export class CreateShipmentDto { @MaxLength(2000) cargoDescription: string; - @ApiPropertyOptional({ enum: CargoCategory, example: CargoCategory.ELECTRONICS }) + @ApiPropertyOptional({ + enum: CargoCategory, + example: CargoCategory.ELECTRONICS, + }) @IsOptional() @IsEnum(CargoCategory) cargoCategory?: CargoCategory; diff --git a/backend/src/shipments/eta.service.ts b/backend/src/shipments/eta.service.ts index bb90613a..3eda896f 100644 --- a/backend/src/shipments/eta.service.ts +++ b/backend/src/shipments/eta.service.ts @@ -24,7 +24,8 @@ const ZONE_MAP: Record = { function resolveZone(location: string): string { const l = location.toUpperCase(); - if (/\b(US|CA|MX|BR|AR)\b/.test(l)) return l.includes('CA') ? 'CA' : l.includes('MX') ? 'MX' : 'US'; + if (/\b(US|CA|MX|BR|AR)\b/.test(l)) + return l.includes('CA') ? 'CA' : l.includes('MX') ? 'MX' : 'US'; if (/\b(UK|DE|FR|IT|ES|NL|PL|SE)\b/.test(l)) return 'EU'; if (/\b(CN|JP|KR|IN|SG|TH|VN)\b/.test(l)) return 'AS'; return 'US'; diff --git a/backend/src/shipments/shipment-template.service.ts b/backend/src/shipments/shipment-template.service.ts index 39008870..c62998fe 100644 --- a/backend/src/shipments/shipment-template.service.ts +++ b/backend/src/shipments/shipment-template.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; export interface ShipmentTemplate { id: string; @@ -50,8 +54,16 @@ export class ShipmentTemplateService { this.templates.delete(id); } - buildShipmentFromTemplate(id: string, userId: string): Omit { - const { name: _n, id: _id, userId: _u, ...shipmentData } = this.findOne(id, userId); + buildShipmentFromTemplate( + id: string, + userId: string, + ): Omit { + const { + name: _n, + id: _id, + userId: _u, + ...shipmentData + } = this.findOne(id, userId); return shipmentData; } } diff --git a/backend/src/shipments/shipments.analytics.spec.ts b/backend/src/shipments/shipments.analytics.spec.ts index 2abdda08..12456ea1 100644 --- a/backend/src/shipments/shipments.analytics.spec.ts +++ b/backend/src/shipments/shipments.analytics.spec.ts @@ -24,6 +24,9 @@ function makeUser(overrides: Partial = {}): User { verificationTokenExpiry: null, resetPasswordToken: null, resetPasswordExpiry: null, + isTwoFactorEnabled: false, + twoFactorSecret: '', + recoveryCodes: [], createdAt: new Date(), updatedAt: new Date(), ...overrides, diff --git a/backend/src/shipments/shipments.service.spec.ts b/backend/src/shipments/shipments.service.spec.ts index 1c409039..2b42b3cc 100644 --- a/backend/src/shipments/shipments.service.spec.ts +++ b/backend/src/shipments/shipments.service.spec.ts @@ -34,6 +34,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: null as unknown as string, + recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/sms/sms.module.ts b/backend/src/sms/sms.module.ts index a7d1d55e..06e441c7 100644 --- a/backend/src/sms/sms.module.ts +++ b/backend/src/sms/sms.module.ts @@ -7,4 +7,4 @@ import { SmsService } from './sms.service'; providers: [SmsService], exports: [SmsService], }) -export class SmsModule {} \ No newline at end of file +export class SmsModule {} diff --git a/backend/src/sms/sms.service.ts b/backend/src/sms/sms.service.ts index a582d731..34d3c9a4 100644 --- a/backend/src/sms/sms.service.ts +++ b/backend/src/sms/sms.service.ts @@ -23,4 +23,4 @@ export class SmsService { this.logger.warn(`Failed to send SMS to ${to}: ${msg}`); } } -} \ No newline at end of file +} diff --git a/backend/src/users/entities/two-factor-recovery.entity.ts b/backend/src/users/entities/two-factor-recovery.entity.ts index 38076f88..743b36ec 100644 --- a/backend/src/users/entities/two-factor-recovery.entity.ts +++ b/backend/src/users/entities/two-factor-recovery.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; import { User } from './user.entity'; @Entity('two_factor_recoveries') @@ -21,4 +28,4 @@ export class TwoFactorRecovery { @ManyToOne(() => User, (user) => user.recoveryCodes, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'userId' }) user: User; -} \ No newline at end of file +} diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 6fecd6c6..9a31e471 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -27,6 +27,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: 'secret-key', + recoveryCodes: [], ...overrides, }; }