diff --git a/phase-1-foundation.md b/phase-1-foundation.md deleted file mode 100644 index f58b637..0000000 --- a/phase-1-foundation.md +++ /dev/null @@ -1,29 +0,0 @@ -# Phase 1: Foundation - -This phase establishes the core foundation for the application, including: - -- Core module structure and organization -- Authentication and authorization setup -- Database schema and Prisma configuration -- Core enums and constants -- Shared utilities and interceptors -- Basic module scaffolding - -## Modules Covered - -- Auth (OTP, sender, authorization) -- User management -- Vehicle management -- Package handling -- Pricing and financial operations -- Notifications and messaging -- S3 storage integration -- Dashboard and mapping features - -## Key Decisions - -- NestJS module organization with feature-based grouping -- Prisma ORM for database interactions -- JWT-based authentication -- S3 for file storage -- Class-validator for DTO validation \ No newline at end of file diff --git a/src/common/config/config-names.ts b/src/common/config/config-names.ts index ba49421..9e4b605 100644 --- a/src/common/config/config-names.ts +++ b/src/common/config/config-names.ts @@ -6,6 +6,7 @@ export const ConfigKey = { Database: { Url: 'DATABASE_URL', + TransactionTimeoutMs: 'PRISMA_TX_TIMEOUT_MS', }, Redis: { Url: 'REDIS_URL', diff --git a/src/common/config/env.validation.ts b/src/common/config/env.validation.ts index 387f55a..2f7001e 100644 --- a/src/common/config/env.validation.ts +++ b/src/common/config/env.validation.ts @@ -61,6 +61,7 @@ export class EnvSchema { @IsOptional() @IsInt() SEND_WINDOW?: number; @IsOptional() @IsInt() BASE_BLOCK_TIME?: number; @IsOptional() @IsInt() CORRIDOR_WIDTH?: number; + @IsOptional() @IsInt() PRISMA_TX_TIMEOUT_MS?: number; @IsOptional() @IsString() AWS_REGION?: string; diff --git a/src/common/utils/phone.spec.ts b/src/common/utils/phone.spec.ts new file mode 100644 index 0000000..c51487b --- /dev/null +++ b/src/common/utils/phone.spec.ts @@ -0,0 +1,22 @@ +import { maskPhoneNumber } from './phone'; + +describe('maskPhoneNumber', () => { + describe('masking', () => { + it.each([ + ['+989121112233', '+98•••••233'], + ['+98912111233', '+98•••••233'], + ['09121112233', '091•••••233'], + ])('should mask %p as %p', (input, expected) => { + expect(maskPhoneNumber(input)).toBe(expected); + }); + + it('should mask short numbers without a suffix', () => { + expect(maskPhoneNumber('+98912')).toBe('+98•••••'); + }); + + it('should fully mask tiny or empty input', () => { + expect(maskPhoneNumber('+98')).toBe('•••••'); + expect(maskPhoneNumber('')).toBe('•••••'); + }); + }); +}); diff --git a/src/common/utils/phone.ts b/src/common/utils/phone.ts new file mode 100644 index 0000000..d53013b --- /dev/null +++ b/src/common/utils/phone.ts @@ -0,0 +1,20 @@ +/** + * Phone-number masking for public responses (Phase 4 Task 5, S-8). + * + * Keeps the country prefix and the last three digits so UIs can still + * display a recognizable hint without exposing PII: + * `+989121112233` → `+98•••••233`. + */ +const VISIBLE_PREFIX_LENGTH = 3; +const VISIBLE_SUFFIX_LENGTH = 3; +const MASK = '•••••'; + +export function maskPhoneNumber(phoneNumber: string): string { + if (!phoneNumber || phoneNumber.length <= VISIBLE_PREFIX_LENGTH) { + return MASK; + } + if (phoneNumber.length <= VISIBLE_PREFIX_LENGTH + VISIBLE_SUFFIX_LENGTH) { + return `${phoneNumber.slice(0, VISIBLE_PREFIX_LENGTH)}${MASK}`; + } + return `${phoneNumber.slice(0, VISIBLE_PREFIX_LENGTH)}${MASK}${phoneNumber.slice(-VISIBLE_SUFFIX_LENGTH)}`; +} diff --git a/src/modules/map/map.service.spec.ts b/src/modules/map/map.service.spec.ts index 86653d7..21d2165 100644 --- a/src/modules/map/map.service.spec.ts +++ b/src/modules/map/map.service.spec.ts @@ -3,19 +3,18 @@ import { MapService } from './map.service'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; -import { PrismaClient } from '../../../generated/prisma'; -import { PrismaService } from '../prisma/prisma.service'; +import { CityRepository } from '../prisma/repositories/city.repository'; describe('MapService', () => { let service: MapService; let httpService: DeepMockProxy; let configService: DeepMockProxy; - let prismaService: DeepMockProxy; + let cities: DeepMockProxy; beforeEach(async () => { httpService = mockDeep(); configService = mockDeep(); - prismaService = mockDeep(); + cities = mockDeep(); configService.get.mockImplementation((key: string, defaultValue?: any) => { const config = { @@ -30,7 +29,7 @@ describe('MapService', () => { MapService, { provide: HttpService, useValue: httpService }, { provide: ConfigService, useValue: configService }, - { provide: PrismaService, useValue: prismaService }, + { provide: CityRepository, useValue: cities }, ], }).compile(); @@ -40,4 +39,34 @@ describe('MapService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getIntermediateCitiesWithIds', () => { + it('should resolve cities through the repository and delegate', async () => { + const originCity = { id: 'origin', latitude: '35.6', longitude: '51.3' }; + const destinationCity = { + id: 'dest', + latitude: '35.9', + longitude: '51.6', + }; + cities.findCityOrThrow + .mockResolvedValueOnce(originCity as any) + .mockResolvedValueOnce(destinationCity as any); + const inner = jest + .spyOn(service as any, 'getIntermediateCities') + .mockResolvedValue([]); + + const result = await service.getIntermediateCitiesWithIds( + 'origin', + 'dest', + ); + + expect(result).toEqual([]); + expect(cities.findCityOrThrow).toHaveBeenCalledWith('origin'); + expect(cities.findCityOrThrow).toHaveBeenCalledWith('dest'); + expect(inner).toHaveBeenCalledWith( + { latitude: '35.6', longitude: '51.3' }, + { latitude: '35.9', longitude: '51.6' }, + ); + }); + }); }); diff --git a/src/modules/map/map.service.ts b/src/modules/map/map.service.ts index 8b64ee4..187379c 100644 --- a/src/modules/map/map.service.ts +++ b/src/modules/map/map.service.ts @@ -17,8 +17,7 @@ import { HttpService } from '@nestjs/axios'; import { firstValueFrom } from 'rxjs'; import { AxiosResponse } from 'axios'; import { CityDto } from './dto/city.dto'; -import { formatPrismaError } from '../../common/utilities'; -import { PrismaService } from '../prisma/prisma.service'; +import { CityRepository } from '../prisma/repositories/city.repository'; import { CoordinatesQueryDto } from './coordinates-query.dto'; @Injectable() @@ -29,7 +28,7 @@ export class MapService { constructor( private httpService: HttpService, config: ConfigService, - private prisma: PrismaService, + private cities: CityRepository, ) { this.mapApiKey = config.getOrThrow('MAP_API_KEY'); this.mapApiUrl = config.getOrThrow('MAP_API_URL'); @@ -112,23 +111,9 @@ export class MapService { } async getIntermediateCitiesWithIds(originId: string, destinationId: string) { - const originCity = await this.prisma.city - .findUniqueOrThrow({ - where: { id: originId }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); + const originCity = await this.cities.findCityOrThrow(originId); - const destinationCity = await this.prisma.city - .findUniqueOrThrow({ - where: { id: destinationId }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); + const destinationCity = await this.cities.findCityOrThrow(destinationId); return this.getIntermediateCities( { diff --git a/src/modules/notification/notification-messages.spec.ts b/src/modules/notification/notification-messages.spec.ts new file mode 100644 index 0000000..e223258 --- /dev/null +++ b/src/modules/notification/notification-messages.spec.ts @@ -0,0 +1,65 @@ +import { + getNotificationMessage, + NotificationMessages, +} from './notification-messages'; + +/** + * Placeholder substitution (Phase 4 Task 6). + * + * Locks the current template behavior: templates carry a `#` prefix + * (`#{packageCode}`) but substitution targets single-brace `{key}`, so the + * rendered message keeps the `#` (`#123`). Missing keys are left untouched. + */ +describe('getNotificationMessage', () => { + describe('substitution', () => { + it('should substitute package and trip codes keeping the hash prefix', () => { + expect( + getNotificationMessage(NotificationMessages.PackageCreated, { + packageCode: 123, + }), + ).toBe( + '📦 بسته شما با کد #123 با موفقیت ایجاد شد. حال می‌توانید برای سفرهای متناسب درخواست بفرستید.', + ); + }); + + it('should substitute every placeholder of a multi-key template', () => { + expect( + getNotificationMessage(NotificationMessages.TripRequestAccepted, { + tripCode: 7, + packageCode: 42, + }), + ).toBe('✅ عالی! درخواست سفر #7 برای بسته #42 پذیرفته شد.'); + }); + + it('should substitute single-brace placeholders without hash', () => { + expect( + getNotificationMessage(NotificationMessages.NewTransporterNote, { + transporterName: 'رضا', + packageCode: 5, + noteContent: 'سلام', + }), + ).toBe('📝 سفیر رضا برای بسته #5 یک یادداشت جدید ارسال کرده: سلام.'); + }); + }); + + describe('missing context', () => { + it('should leave missing placeholders untouched', () => { + const message = getNotificationMessage( + NotificationMessages.TripRequestAccepted, + ); + + expect(message).toContain('{tripCode}'); + expect(message).toContain('{packageCode}'); + }); + + it('should substitute only the provided keys', () => { + const message = getNotificationMessage( + NotificationMessages.TripRequestAccepted, + { tripCode: 7 }, + ); + + expect(message).toContain('#7'); + expect(message).toContain('{packageCode}'); + }); + }); +}); diff --git a/src/modules/package/application/package.service.spec.ts b/src/modules/package/application/package.service.spec.ts new file mode 100644 index 0000000..eb6f9db --- /dev/null +++ b/src/modules/package/application/package.service.spec.ts @@ -0,0 +1,426 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PackageService } from './package.service'; +import { MatchingService } from '../matching.service'; +import { TripService } from '../../trip/application/trip.service'; +import { S3Service } from '../../s3/s3.service'; +import { TurfService } from '../../turf/turf.service'; +import { PricingService } from '../../pricing/pricing.service'; +import { MapService } from '../../map/map.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient, PackageStatusEnum } from '../../../../generated/prisma'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + AuthMessages, + BadRequestMessages, +} from '../../../common/enums/messages.enum'; +import { CreatePackageDto } from '../dto/create-package.dto'; +import { UpdatePackageDto } from '../dto/update.package.dto'; +import { NotificationService } from '../../notification/notification.service'; + +describe('PackageService', () => { + let service: PackageService; + let prisma: DeepMockProxy; + let mapService: DeepMockProxy; + let pricingService: DeepMockProxy; + let matchingService: DeepMockProxy; + let tripService: DeepMockProxy; + let s3Service: DeepMockProxy; + let turfService: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + + const mockOriginAddress = { + id: 'address-123', + userId: 'user-123', + title: 'خانه', + latitude: 35.6892, + longitude: 51.389, + city: 'تهران', + province: 'تهران', + } as any; + + const mockRecipient = { + id: 'recipient-123', + fullName: 'علی احمدی', + phoneNumber: '+989123456788', + address: { + userId: 'user-123', + title: 'دفتر کار', + city: 'تهران', + province: 'تهران', + street: 'خیابان ولیعصر', + details: 'روبروی ساختمان قدیم', + latitude: '35.7219', + longitude: '51.3347', + postalCode: '1234567890', + }, + } as any; + + const mockPackage = { + id: 'package-123', + senderId: 'user-123', + items: ['کتاب', 'لپ‌تاپ'], + weight: 2000, + dimensions: '30x20x15', + status: PackageStatusEnum.created, + suggestedPrice: 50000, + finalPrice: 50000, + isFragile: false, + isPerishable: false, + pickupAtOrigin: true, + deliveryAtDestination: true, + picturesKey: ['pic1.jpg', 'pic2.jpg'], + originAddress: mockOriginAddress, + recipient: mockRecipient, + deletedAt: null, + } as any; + + const mockTrip = { + id: 'trip-123', + status: 'scheduled', + origin: { latitude: 35.6892, longitude: 51.389 }, + destination: { latitude: 35.7219, longitude: 51.3347 }, + waypoints: [], + normalDistanceKm: 10, + normalDurationMin: 30, + totalDeviationDurationMin: 0, + matchedRequests: [], + } as any; + + const mockBreakdown = { + basePrice: 20000, + distanceCost: 15000, + weightCost: 10000, + specialHandlingCost: 0, + cityPremiumCost: 5000, + }; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + mapService = mockDeep(); + pricingService = mockDeep(); + matchingService = mockDeep(); + tripService = mockDeep(); + s3Service = mockDeep(); + turfService = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PackageService, + { provide: PrismaService, useValue: prisma }, + { provide: MapService, useValue: mapService }, + { provide: PricingService, useValue: pricingService }, + { provide: MatchingService, useValue: matchingService }, + { provide: TripService, useValue: tripService }, + { provide: S3Service, useValue: s3Service }, + { provide: TurfService, useValue: turfService }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + ], + }).compile(); + + service = module.get(PackageService); + }); + + describe('create', () => { + const packageDto: CreatePackageDto = { + items: ['کتاب'], + originAddressId: 'address-123', + recipientId: 'recipient-123', + weight: 1500, + dimensions: '25x20x10', + isFragile: false, + isPerishable: false, + pickupAtOrigin: true, + deliveryAtDestination: true, + picturesKey: ['pic1.jpg'], + } as any; + + it('should create package successfully', async () => { + prisma.address.findFirst.mockResolvedValue(mockOriginAddress); + prisma.packageRecipient.findFirst.mockResolvedValue(mockRecipient); + prisma.package.create.mockResolvedValue(mockPackage); + + mapService.calculateDistance.mockResolvedValue({ + distance: 15, + duration: 25, + }); + pricingService.calculateSuggestedPrice.mockReturnValue({ + suggestedPrice: 50000, + breakdown: mockBreakdown, + }); + + const result = await service.create('user-123', packageDto); + + expect(result).toEqual(mockPackage); + expect(mapService.calculateDistance).toHaveBeenCalledWith({ + origin: { + latitude: mockOriginAddress.latitude, + longitude: mockOriginAddress.longitude, + }, + destination: { + latitude: mockRecipient.address.latitude, + longitude: mockRecipient.address.longitude, + }, + }); + expect(pricingService.calculateSuggestedPrice).toHaveBeenCalled(); + }); + + it('should throw when origin address not found', async () => { + prisma.address.findFirst.mockResolvedValue(null); + + await expect(service.create('user-123', packageDto)).rejects.toThrow( + new ForbiddenException( + `${AuthMessages.EntityAccessDenied} origin address.`, + ), + ); + }); + + it('should throw when recipient not found', async () => { + prisma.address.findFirst.mockResolvedValue(mockOriginAddress); + prisma.packageRecipient.findFirst.mockResolvedValue(null); + + await expect(service.create('user-123', packageDto)).rejects.toThrow( + new ForbiddenException(`${AuthMessages.EntityAccessDenied} recipient.`), + ); + }); + }); + + describe('getById', () => { + it('should return package with presigned URLs', async () => { + const packageWithUrls = { + ...mockPackage, + picturesKey: ['pic1.jpg', 'pic2.jpg'], + picturesUrl: ['url1', 'url2'], + }; + + prisma.package.findFirstOrThrow.mockResolvedValue(mockPackage); + s3Service.generateGetPresignedUrl + .mockResolvedValueOnce('url1') + .mockResolvedValueOnce('url2'); + + const result = await service.getById('package-123'); + + expect(result).toMatchObject(packageWithUrls); + }); + + it('should handle null picturesKey', async () => { + const packageWithNullPics = { ...mockPackage, picturesKey: null }; + prisma.package.findFirstOrThrow.mockResolvedValue(packageWithNullPics); + + const result = await service.getById('package-123'); + + expect(result.picturesKey).toBeNull(); + expect(result.picturesUrl).toBeUndefined(); + }); + + it('should default to the injected client when no tx is provided', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + ...mockPackage, + picturesKey: null, + }); + + const result = await service.getById('package-123'); + + expect(result.id).toBe('package-123'); + expect(prisma.package.findFirstOrThrow).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'package-123', deletedAt: null }, + }), + ); + }); + }); + + describe('getAll', () => { + it('should map packages with presigned urls', async () => { + prisma.package.findMany.mockResolvedValue([mockPackage]); + s3Service.generateGetPresignedUrl.mockResolvedValue('url1'); + + const result = await service.getAll('user-123'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: 'package-123', + picturesUrl: ['url1', 'url1'], + picturesKey: undefined, + }); + }); + + it('should yield undefined entries for non-array picturesKey (known quirk)', async () => { + prisma.package.findMany.mockResolvedValue([ + { ...mockPackage, picturesKey: null }, + ]); + + const result = await service.getAll('user-123'); + + expect(result).toEqual([undefined]); + }); + }); + + describe('update', () => { + const updateDto: UpdatePackageDto = { + finalPrice: 60000, + } as any; + + it('should update package successfully', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.created, + suggestedPrice: 50000, + } as any); + prisma.package.update.mockResolvedValue({ ...mockPackage, ...updateDto }); + + const result = await service.update('package-123', updateDto); + + expect(result).toEqual({ ...mockPackage, ...updateDto }); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123' }, + data: updateDto, + }); + }); + + it('should throw when package status is invalid', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.matched, + suggestedPrice: 50000, + } as any); + + await expect(service.update('package-123', updateDto)).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus} ${PackageStatusEnum.matched}.`, + ), + ); + }); + + it('should throw when final price is below suggested price', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.created, + suggestedPrice: 50000, + } as any); + + await expect( + service.update('package-123', { finalPrice: 40000 } as any), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.InvalidPrice), + ); + }); + }); + + describe('delete', () => { + it('should soft-delete package successfully', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.created, + } as any); + prisma.package.update.mockResolvedValue({ + ...mockPackage, + deletedAt: new Date(), + }); + + const result = await service.delete('package-123'); + + expect(result.deletedAt).toBeInstanceOf(Date); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123' }, + data: { deletedAt: expect.any(Date) }, + }); + }); + + it('should throw when package status is invalid', async () => { + prisma.package.findFirstOrThrow.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + + await expect(service.delete('package-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus} ${PackageStatusEnum.matched}.`, + ), + ); + }); + }); + + describe('getMatchedTrips', () => { + const session = { + packages: [], + } as any; + + it('should return matched trips with deviation info', async () => { + const matchResults = [ + { + tripId: 'trip-123', + isRequestSent: false, + score: 100, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + }, + ]; + + service.getById = jest.fn().mockResolvedValue(mockPackage); + matchingService.findMatchedTrips.mockResolvedValue(matchResults); + tripService.getMultipleById.mockResolvedValue([mockTrip]); + turfService.sortLocationsByRoute.mockReturnValue([] as any); + mapService.calculateDistance.mockResolvedValue({ + distance: 12, + duration: 35, + }); + pricingService.calculateDeviationCost.mockReturnValue(5000); + + const result = await service.getMatchedTrips('package-123', session); + + expect(result).toHaveLength(1); + expect(result[0]?.additionalPrice).toBe(5000); + }); + + it('should throw when package status is invalid', async () => { + const invalidPackage = { + ...mockPackage, + status: PackageStatusEnum.matched, + }; + + service.getById = jest.fn().mockResolvedValue(invalidPackage); + + await expect( + service.getMatchedTrips('package-123', session), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.SendRequestPackage), + ); + }); + }); + + describe('generatePackagePicPresignedUrl', () => { + it('should resolve presigned urls for string keys', async () => { + s3Service.generateGetPresignedUrl.mockResolvedValue('url1'); + + const result = await service.generatePackagePicPresignedUrl(['pic1.jpg']); + + expect(result).toEqual(['url1']); + expect(s3Service.generateGetPresignedUrl).toHaveBeenCalledWith( + 'pic1.jpg', + ); + }); + + it('should log and return empty string when key extraction fails', async () => { + const loggerSpy = jest + .spyOn((service as any).logger, 'error') + .mockImplementation(() => undefined); + s3Service.generateGetPresignedUrl.mockImplementation(() => { + throw new Error('S3 down'); + }); + + const result = await service.generatePackagePicPresignedUrl(['pic1.jpg']); + + expect(result).toEqual(['']); + expect(loggerSpy).toHaveBeenCalledWith( + 'Failed to generate presigned URL for picturesKey[0]:', + expect.any(Error), + ); + }); + }); +}); diff --git a/src/modules/package/application/package.service.ts b/src/modules/package/application/package.service.ts new file mode 100644 index 0000000..21c2e1c --- /dev/null +++ b/src/modules/package/application/package.service.ts @@ -0,0 +1,508 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { CreatePackageDto } from '../dto/create-package.dto'; +import { + AuthMessages, + BadRequestMessages, +} from '../../../common/enums/messages.enum'; +import { PackageStatusEnum } from '../../../../generated/prisma'; +import { MapService } from '../../map/map.service'; +import { PricingService } from '../../pricing/pricing.service'; +import { S3Service } from '../../s3/s3.service'; +import { SessionData } from 'express-session'; +import { MatchingService } from '../matching.service'; +import { TripService } from '../../trip/application/trip.service'; +import { PrismaTransaction } from '../../prisma/prisma.types'; +import { UpdatePackageDto } from '../dto/update.package.dto'; +import { instanceToPlain } from 'class-transformer'; +import { TurfService } from '../../turf/turf.service'; +import { NotificationService } from '../../notification/notification.service'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; + +/** + * Package lifecycle + CRUD orchestration (Phase 4 Task 5). + * + * Slimmed from the 22 KB god service: recipients live in + * `RecipientService`, package-side requests in `PackageRequestService`, + * public tracking in `TrackingService`. All multi-statement writes run + * through `TransactionRunner` (no manual `.catch` — the global filter maps + * Prisma errors). Fixes vs the original: `update`/`delete` now write + * through `tx` (they used the root client inside the transaction), and + * `generatePackagePicPresignedUrl` uses Nest `Logger` instead of + * `console.error` and no longer imports the internal + * `generated/prisma/runtime/library` `JsonArray` type (B-9). + * + * Known quirk preserved: `getAll` yields `undefined` entries for packages + * whose `picturesKey` is not an array. + */ +@Injectable() +export class PackageService { + private readonly logger = new Logger(PackageService.name); + + constructor( + private prisma: PrismaService, + private mapService: MapService, + private pricingService: PricingService, + private matchingService: MatchingService, + private tripService: TripService, + private s3Service: S3Service, + private turfService: TurfService, + private notificationService: NotificationService, + private runner: TransactionRunner, + ) {} + + async create( + userId: string, + { items, originAddressId, recipientId, ...packageDto }: CreatePackageDto, + ) { + return this.runner.run(async (tx) => { + const originAddress = await tx.address.findFirst({ + where: { + id: originAddressId, + userId, + }, + }); + + if (!originAddress) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} origin address.`, + ); + } + + const recipient = await tx.packageRecipient.findFirst({ + where: { + id: recipientId, + address: { + userId, + }, + }, + include: { + address: true, + }, + }); + + if (!recipient) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} recipient.`, + ); + } + + // Calculate distance for pricing + const { distance } = await this.mapService.calculateDistance({ + origin: { + latitude: originAddress.latitude, + longitude: originAddress.longitude, + }, + destination: { + latitude: recipient.address.latitude, + longitude: recipient.address.longitude, + }, + }); + + const { suggestedPrice, breakdown } = + this.pricingService.calculateSuggestedPrice({ + distanceKm: distance, + weightGr: packageDto.weight, + isFragile: packageDto.isFragile ?? false, + isPerishable: packageDto.isPerishable ?? false, + originCity: originAddress.city!, + destinationCity: recipient.address.city!, + }); + + const plainBreakdown = instanceToPlain(breakdown); + + const packageData = await tx.package.create({ + data: { + senderId: userId, + items, + originAddressId: originAddress.id, + recipientId: recipient.id, + ...packageDto, + suggestedPrice, + breakdown: plainBreakdown, + finalPrice: suggestedPrice, + }, + include: { + originAddress: true, + recipient: { + include: { + address: true, + }, + }, + }, + }); + + // Add create package notification + await this.notificationService.create( + userId, + { + packageId: packageData.id, + content: getNotificationMessage(NotificationMessages.PackageCreated, { + packageCode: packageData.code, + }), + }, + tx, + ); + + return packageData; + }); + } + + async getById(id: string, tx: PrismaTransaction = this.prisma) { + const packageData = await tx.package.findFirstOrThrow({ + where: { + id, + deletedAt: null, + }, + include: { + originAddress: true, + recipient: { + include: { + address: true, + }, + }, + matchedRequest: { + select: { + trip: { + select: { + transporter: { + select: { + rate: true, + user: { + select: { + firstName: true, + lastName: true, + gender: true, + phoneNumber: true, + }, + }, + }, + }, + vehicle: { + select: { + vehicleType: true, + model: { + select: { + brand: true, + }, + }, + manufactureYear: true, + color: true, + }, + }, + departureTime: true, + status: true, + description: true, + }, + }, + trackingCode: true, + deliveryCode: true, + transporterNotes: true, + senderRating: true, + senderComment: true, + isCompleted: true, + pickupTime: true, + deliveryTime: true, + paymentStatus: true, + }, + }, + }, + }); + + const keys = packageData.picturesKey; + let picturesUrl: string[] | undefined; + if (keys && Array.isArray(keys)) { + picturesUrl = await this.generatePackagePicPresignedUrl(keys); + } + + return { + ...packageData, + picturesUrl, + }; + } + + async getAll( + userId: string, + status: PackageStatusEnum[] = [ + PackageStatusEnum.created, + PackageStatusEnum.searching_transporter, + PackageStatusEnum.matched, + PackageStatusEnum.picked_up, + PackageStatusEnum.in_transit, + ], + page = 1, + limit = 10, + ) { + const skip = (page - 1) * limit; + const packages = await this.prisma.package.findMany({ + where: { + senderId: userId, + status: { + in: status, + }, + deletedAt: null, + }, + include: { + originAddress: true, + recipient: { + include: { + address: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: limit, + }); + + return Promise.all( + packages.map(async (packageData) => { + const keys = packageData.picturesKey; + if (keys && Array.isArray(keys)) { + return { + ...packageData, + picturesUrl: await this.generatePackagePicPresignedUrl(keys), + picturesKey: undefined, + }; + } + }), + ); + } + + async update(id: string, packageDto: UpdatePackageDto) { + return this.runner.run(async (tx) => { + const { status, suggestedPrice } = await tx.package.findFirstOrThrow({ + where: { + id, + deletedAt: null, + }, + select: { + status: true, + suggestedPrice: true, + }, + }); + + const isValidStatus = + status === PackageStatusEnum.created || + status === PackageStatusEnum.searching_transporter; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus} ${status}.`, + ); + } + + if (packageDto.finalPrice < suggestedPrice) { + throw new BadRequestException(BadRequestMessages.InvalidPrice); + } + + return tx.package.update({ + where: { id }, + data: packageDto, + }); + }); + } + + async generatePackagePicPresignedUrl(keys: unknown[]) { + return Promise.all( + keys.map(async (key, index) => { + const keyString = JSON.stringify(key).split('"')[1]; + try { + if (keyString) { + return this.s3Service.generateGetPresignedUrl(keyString); + } + return ''; + } catch (urlError) { + this.logger.error( + `Failed to generate presigned URL for picturesKey[${index}]:`, + urlError, + ); + return ''; + } + }), + ); + } + + private async updateStatus( + id: string, + status: PackageStatusEnum, + tx: PrismaTransaction = this.prisma, + ) { + return tx.package.update({ + where: { + id, + deletedAt: null, + }, + data: { + status, + }, + }); + } + + async delete(id: string) { + return this.runner.run(async (tx) => { + const { status } = await tx.package.findFirstOrThrow({ + where: { id }, + select: { status: true }, + }); + + const isValidStatus = + status === PackageStatusEnum.created || + status === PackageStatusEnum.searching_transporter; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus} ${status}.`, + ); + } + + return tx.package.update({ + where: { id }, + data: { + deletedAt: new Date(), + }, + }); + }); + } + + async getMatchedTrips( + packageId: string, + session: SessionData, + maxResults = 20, + ) { + return this.runner.run(async (tx) => { + const packageData = await this.getById(packageId, tx); + + const idValidPackageStatus = + packageData.status === PackageStatusEnum.created || + packageData.status === PackageStatusEnum.searching_transporter || + packageData.status === PackageStatusEnum.cancelled; + if (!idValidPackageStatus) { + throw new BadRequestException(BadRequestMessages.SendRequestPackage); + } + + // Update package status + await this.updateStatus( + packageId, + PackageStatusEnum.searching_transporter, + tx, + ); + + // Do matching + const matchedTrips = await this.matchingService.findMatchedTrips( + packageData, + session, + maxResults, + tx, + ); + + // Fetch trips + const tripIds = matchedTrips + .filter((m) => !m.isRequestSent) + .slice(0, maxResults) + .map((m) => m.tripId); + const trips = await this.tripService.getMultipleById(tripIds, tx); + const tripMap = new Map(trips.map((trip) => [trip.id, trip])); + + // Calculate deviation info + const matchingResult = await Promise.all( + matchedTrips.map(async (matchedTrip) => { + const trip = tripMap.get(matchedTrip.tripId); + if (!trip) return; + + const waypoints = trip.matchedRequests + .flatMap((r) => [ + r.package.pickupAtOrigin + ? { + latitude: r.package.originAddress.latitude, + longitude: r.package.originAddress.longitude, + } + : undefined, + r.package.deliveryAtDestination + ? { + latitude: r.package.recipient.address.latitude, + longitude: r.package.recipient.address.longitude, + } + : undefined, + ]) + .filter((v) => v !== undefined); + + waypoints.push( + ...[ + packageData.pickupAtOrigin + ? { + latitude: packageData.originAddress.latitude, + longitude: packageData.originAddress.longitude, + } + : undefined, + packageData.deliveryAtDestination + ? { + latitude: packageData.recipient.address.latitude, + longitude: packageData.recipient.address.longitude, + } + : undefined, + ].filter((v) => v !== undefined), + ); + + const sortedWaypoints = this.turfService.sortLocationsByRoute( + trip.origin, + trip.destination, + waypoints, + ); + + const { distance, duration } = + await this.mapService.calculateDistance({ + origin: { + latitude: trip.origin.latitude, + longitude: trip.origin.longitude, + }, + destination: { + latitude: trip.destination.latitude, + longitude: trip.destination.longitude, + }, + waypoints: sortedWaypoints, + }); + + const deviationDistance = Math.max( + 0, + distance - + ((trip.normalDistanceKm ?? 0) + + (trip.totalDeviationDurationMin ?? 0)), + ); + const deviationDuration = Math.max( + 0, + duration - + ((trip.normalDurationMin ?? 0) + + (trip.totalDeviationDurationMin ?? 0)), + ); + + const additionalPrice = this.pricingService.calculateDeviationCost( + deviationDistance, + deviationDuration, + ); + matchedTrip.deviationInfo = { + distance: deviationDistance, + duration: deviationDuration, + additionalPrice, + }; + + return { + ...trip, + additionalPrice, + }; + }), + ); + + return matchingResult.filter(Boolean); + }); + } +} diff --git a/src/modules/package/domain/package-request.service.spec.ts b/src/modules/package/domain/package-request.service.spec.ts new file mode 100644 index 0000000..7d9a3ce --- /dev/null +++ b/src/modules/package/domain/package-request.service.spec.ts @@ -0,0 +1,387 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PackageRequestService } from './package-request.service'; +import { PricingService } from '../../pricing/pricing.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { + PrismaClient, + PackageStatusEnum, + RequestStatusEnum, + TripStatusEnum, +} from '../../../../generated/prisma'; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { + AuthMessages, + BadRequestMessages, + NotFoundMessages, +} from '../../../common/enums/messages.enum'; +import { CreateRequestDto } from '../../trip/dto/create-request.dto'; +import { NotificationService } from '../../notification/notification.service'; + +describe('PackageRequestService', () => { + let service: PackageRequestService; + let prisma: DeepMockProxy; + let pricingService: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + + const mockPackage = { + id: 'package-123', + senderId: 'user-123', + status: PackageStatusEnum.searching_transporter, + finalPrice: 50000, + code: 1001, + } as any; + + const mockTrip = { + id: 'trip-123', + status: TripStatusEnum.scheduled, + code: 2001, + } as any; + + function sessionWithMatch(isRequestSent = false) { + return { + packages: [ + { + id: 'package-123', + matchResults: [ + { + tripId: 'trip-123', + isRequestSent, + score: 100, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + deviationInfo: { + distance: 2, + duration: 5, + additionalPrice: 3000, + }, + }, + ], + }, + ], + } as any; + } + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + pricingService = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PackageRequestService, + { provide: PrismaService, useValue: prisma }, + { provide: PricingService, useValue: pricingService }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + ], + }).compile(); + + service = module.get(PackageRequestService); + }); + + describe('createRequest', () => { + const requestDto: CreateRequestDto = { + packageId: 'package-123', + tripId: 'trip-123', + senderNote: 'لطفا با دقت حمل کنید', + }; + + it('should create request successfully', async () => { + const createdRequest = { + id: 'request-123', + packageId: 'package-123', + tripId: 'trip-123', + status: RequestStatusEnum.pending, + offeredPrice: 45000, + deviationCost: 3000, + }; + const session = sessionWithMatch(); + + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue(mockTrip); + prisma.tripRequest.create.mockResolvedValue(createdRequest as any); + pricingService.calculateTransporterEarnings.mockReturnValue(42000); + + const result = await service.createRequest( + 'user-123', + requestDto, + session, + ); + + expect(result).toEqual(createdRequest); + expect(session.packages[0].matchResults[0].isRequestSent).toBe(true); + expect(prisma.tripRequest.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + packageId: 'package-123', + tripId: 'trip-123', + deviationDistanceKm: 2, + deviationDurationMin: 5, + deviationCost: 3000, + offeredPrice: 45000, + }), + }); + }); + + it('should throw when user does not own package', async () => { + prisma.package.findUniqueOrThrow.mockResolvedValue({ + ...mockPackage, + senderId: 'other-user', + }); + + await expect( + service.createRequest('user-123', requestDto, sessionWithMatch()), + ).rejects.toThrow( + new ForbiddenException(`${AuthMessages.EntityAccessDenied} package.`), + ); + }); + + it('should throw when package status is invalid', async () => { + prisma.package.findUniqueOrThrow.mockResolvedValue({ + ...mockPackage, + status: PackageStatusEnum.matched, + }); + + await expect( + service.createRequest('user-123', requestDto, sessionWithMatch()), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.SendRequestPackage), + ); + }); + + it('should throw when trip status is invalid', async () => { + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.completed, + }); + + await expect( + service.createRequest('user-123', requestDto, sessionWithMatch()), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.SendRequestTrip), + ); + }); + + it('should throw when no matched trips found', async () => { + const emptySession = { + packages: [], + } as any; + + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue(mockTrip); + + await expect( + service.createRequest('user-123', requestDto, emptySession), + ).rejects.toThrow(new NotFoundException(NotFoundMessages.MatchedTrip)); + }); + + it('should throw when trip is not in matched trips', async () => { + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue(mockTrip); + + await expect( + service.createRequest( + 'user-123', + { ...requestDto, tripId: 'trip-unknown' }, + sessionWithMatch(), + ), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.SendRequestTrip), + ); + }); + + it('should not touch the session when the transaction fails (A-4)', async () => { + const session = sessionWithMatch(); + prisma.package.findUniqueOrThrow.mockResolvedValue(mockPackage); + prisma.trip.findUniqueOrThrow.mockResolvedValue(mockTrip); + prisma.tripRequest.create.mockResolvedValue({ id: 'request-123' } as any); + pricingService.calculateTransporterEarnings.mockReturnValue(42000); + notificationService.create.mockRejectedValue( + new Error('Redis/notification down'), + ); + + await expect( + service.createRequest('user-123', requestDto, session), + ).rejects.toThrow('Redis/notification down'); + expect(session.packages[0].matchResults[0].isRequestSent).toBe(false); + }); + }); + + describe('getAllPackageRequests', () => { + it('should return all package requests', async () => { + const requests = [ + { + id: 'request-1', + packageId: 'package-123', + status: RequestStatusEnum.pending, + }, + { + id: 'request-2', + packageId: 'package-123', + status: RequestStatusEnum.accepted, + }, + ]; + + prisma.tripRequest.findMany.mockResolvedValue(requests as any); + + const result = await service.getAllPackageRequests('package-123'); + + expect(result).toEqual(requests); + expect(prisma.tripRequest.findMany).toHaveBeenCalledWith({ + where: { + packageId: 'package-123', + status: { + in: Object.values(RequestStatusEnum), + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }); + + it('should filter by status', async () => { + const pendingRequests = [ + { + id: 'request-1', + packageId: 'package-123', + status: RequestStatusEnum.pending, + }, + ]; + + prisma.tripRequest.findMany.mockResolvedValue(pendingRequests as any); + + await service.getAllPackageRequests('package-123', [ + RequestStatusEnum.pending, + ]); + + expect(prisma.tripRequest.findMany).toHaveBeenCalledWith({ + where: { + packageId: 'package-123', + status: { + in: [RequestStatusEnum.pending], + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }); + }); + + describe('updateRequest', () => { + it('should cancel request and update session', async () => { + const canceledRequest = { + id: 'request-123', + packageId: 'package-123', + tripId: 'trip-123', + status: RequestStatusEnum.canceled, + }; + + const session = { + packages: [ + { + id: 'package-123', + matchResults: [ + { + tripId: 'trip-123', + isRequestSent: true, + score: 100, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + }, + ], + }, + ], + } as any; + + prisma.tripRequest.update.mockResolvedValue({ + ...canceledRequest, + package: { id: 'package-123', senderId: 'user-123' }, + } as any); + + const result = await service.updateRequest('request-123', session); + + expect(result).toEqual(canceledRequest); + expect(session.packages[0].matchResults[0].isRequestSent).toBe(false); + expect(prisma.tripRequest.update).toHaveBeenCalledWith({ + where: { + id: 'request-123', + status: RequestStatusEnum.pending, + }, + data: { + status: RequestStatusEnum.canceled, + }, + include: { + package: { + select: { + id: true, + senderId: true, + }, + }, + }, + }); + }); + + it('should handle missing session data gracefully', async () => { + const canceledRequest = { + id: 'request-123', + packageId: 'package-456', + tripId: 'trip-789', + status: RequestStatusEnum.canceled, + }; + + const session = { + packages: [], + } as any; + + prisma.tripRequest.update.mockResolvedValue({ + ...canceledRequest, + package: { id: 'package-456', senderId: 'user-456' }, + } as any); + + const result = await service.updateRequest('request-123', session); + + expect(result).toEqual(canceledRequest); + }); + + it('should not touch the session when the transaction fails (A-4)', async () => { + const session = { + packages: [ + { + id: 'package-123', + matchResults: [{ tripId: 'trip-123', isRequestSent: true }], + }, + ], + } as any; + + prisma.tripRequest.update.mockResolvedValue({ + id: 'request-123', + packageId: 'package-123', + tripId: 'trip-123', + status: RequestStatusEnum.canceled, + package: { id: 'package-123', senderId: 'user-123' }, + } as any); + notificationService.create.mockRejectedValue(new Error('Notify down')); + + await expect( + service.updateRequest('request-123', session), + ).rejects.toThrow('Notify down'); + expect(session.packages[0].matchResults[0].isRequestSent).toBe(true); + }); + }); +}); diff --git a/src/modules/package/domain/package-request.service.ts b/src/modules/package/domain/package-request.service.ts new file mode 100644 index 0000000..e1a1670 --- /dev/null +++ b/src/modules/package/domain/package-request.service.ts @@ -0,0 +1,201 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { + AuthMessages, + BadRequestMessages, + NotFoundMessages, +} from '../../../common/enums/messages.enum'; +import { + PackageStatusEnum, + RequestStatusEnum, + TripStatusEnum, +} from '../../../../generated/prisma'; +import { MatchResult } from '../matching.types'; +import { SessionData } from 'express-session'; +import { PricingService } from '../../pricing/pricing.service'; +import { CreateRequestDto } from '../../trip/dto/create-request.dto'; +import { NotificationService } from '../../notification/notification.service'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; + +/** + * Package-side trip requests (Phase 4 Task 5). + * + * Owns `createRequest`, `getAllPackageRequests` and `updateRequest` + * (= cancel). Session bookkeeping (`isRequestSent`) happens **after** the + * transaction commits: the Redis session is not transactional with + * Postgres, so mutating it inside the tx risks divergence on rollback + * (A-4 mitigation). + */ +@Injectable() +export class PackageRequestService { + constructor( + private prisma: PrismaService, + private pricingService: PricingService, + private notificationService: NotificationService, + private runner: TransactionRunner, + ) {} + + async createRequest( + userId: string, + { packageId, tripId, senderNote }: CreateRequestDto, + session: SessionData, + ) { + let matchedTrip: MatchResult | undefined; + const request = await this.runner.run(async (tx) => { + const packageData = await tx.package.findUniqueOrThrow({ + where: { id: packageId }, + }); + + if (userId !== packageData.senderId) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} package.`, + ); + } + + if (packageData.status !== PackageStatusEnum.searching_transporter) { + throw new BadRequestException(BadRequestMessages.SendRequestPackage); + } + + const tripData = await tx.trip.findUniqueOrThrow({ + where: { id: tripId }, + include: { + origin: true, + destination: true, + waypoints: true, + }, + }); + + if (tripData.status !== TripStatusEnum.scheduled) { + throw new BadRequestException(BadRequestMessages.SendRequestTrip); + } + + const matchedTrips = session.packages.find( + (p) => p.id === packageId, + )?.matchResults; + if (!matchedTrips || !matchedTrips.length) { + throw new NotFoundException(NotFoundMessages.MatchedTrip); + } + + matchedTrip = matchedTrips.find((t) => t.tripId === tripId); + if (!matchedTrip) { + throw new BadRequestException(BadRequestMessages.SendRequestTrip); + } + + const deviationDistance = matchedTrip.deviationInfo?.distance ?? 0; + const deviationDuration = matchedTrip.deviationInfo?.duration ?? 0; + const deviationCost = matchedTrip.deviationInfo?.additionalPrice ?? 0; + const transporterEarnings = + this.pricingService.calculateTransporterEarnings( + packageData.finalPrice, + deviationCost, + ); + const offeredPrice = transporterEarnings + deviationCost; + + const created = await tx.tripRequest.create({ + data: { + packageId, + tripId, + deviationDistanceKm: deviationDistance, + deviationDurationMin: deviationDuration, + deviationCost, + offeredPrice, + senderNote, + }, + }); + + // Add create request notification + await this.notificationService.create( + userId, + { + packageId, + content: getNotificationMessage( + NotificationMessages.TripRequestCreated, + { + packageCode: packageData.code, + tripCode: tripData.code, + }, + ), + }, + tx, + ); + + return created; + }); + + // Update session only after the transaction commits (see class doc). + if (matchedTrip) matchedTrip.isRequestSent = true; + + return request; + } + + async getAllPackageRequests( + packageId: string, + status: RequestStatusEnum[] = Object.values(RequestStatusEnum), + ) { + return this.prisma.tripRequest.findMany({ + where: { + packageId, + status: { + in: status, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async updateRequest(requestId: string, session: SessionData) { + let matchedTrip: MatchResult | undefined; + const request = await this.runner.run(async (tx) => { + const { package: packageData, ...updated } = await tx.tripRequest.update({ + where: { + id: requestId, + status: RequestStatusEnum.pending, + }, + data: { + status: RequestStatusEnum.canceled, + }, + include: { + package: { + select: { + id: true, + senderId: true, + }, + }, + }, + }); + + // Resolve the session entry inside the tx (read-only) but mutate it + // only after commit (see class doc). + matchedTrip = session.packages + .find((p) => p.id === updated.packageId) + ?.matchResults.find((m) => m.tripId === updated.tripId); + + // Add create package notification + await this.notificationService.create( + packageData.senderId, + { + packageId: packageData.id, + content: NotificationMessages.TripRequestCanceled, + }, + tx, + ); + + return updated; + }); + + if (matchedTrip) matchedTrip.isRequestSent = false; + + return request; + } +} diff --git a/src/modules/package/domain/recipient.service.spec.ts b/src/modules/package/domain/recipient.service.spec.ts new file mode 100644 index 0000000..78516f7 --- /dev/null +++ b/src/modules/package/domain/recipient.service.spec.ts @@ -0,0 +1,158 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RecipientService } from './recipient.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { CityRepository } from '../../prisma/repositories/city.repository'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '../../../../generated/prisma'; +import { CreateRecipientDto } from '../dto/create-recipient.dto'; + +describe('RecipientService', () => { + let service: RecipientService; + let prisma: DeepMockProxy; + let runner: DeepMockProxy; + let cities: DeepMockProxy; + + const mockCity = { + id: 'city-123', + name: 'تهران', + province: { + id: 'province-123', + name: 'تهران', + }, + } as any; + + const recipientDto: CreateRecipientDto = { + fullName: 'علی احمدی', + phoneNumber: '+989123456788', + address: { + cityId: 'city-123', + title: 'دفتر کار', + street: 'خیابان ولیعصر', + details: 'روبروی ساختمان قدیم', + latitude: '35.7219', + longitude: '51.3347', + postalCode: '1234567890', + }, + } as any; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + runner = mockDeep(); + cities = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RecipientService, + { provide: PrismaService, useValue: prisma }, + { provide: TransactionRunner, useValue: runner }, + { provide: CityRepository, useValue: cities }, + ], + }).compile(); + + service = module.get(RecipientService); + }); + + describe('createRecipient', () => { + it('should create recipient with denormalized city and province', async () => { + const created = { id: 'recipient-123', ...recipientDto }; + cities.findCityWithProvince.mockResolvedValue(mockCity); + prisma.packageRecipient.create.mockResolvedValue(created as any); + + const result = await service.createRecipient('user-123', recipientDto); + + expect(result).toEqual(created); + expect(cities.findCityWithProvince).toHaveBeenCalledWith( + 'city-123', + prisma, + ); + expect(prisma.packageRecipient.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + address: expect.objectContaining({ + create: expect.objectContaining({ + userId: 'user-123', + province: 'تهران', + city: 'تهران', + }), + }), + }), + include: { address: true }, + }); + }); + + it('should fall back to fullName when address title is missing', async () => { + const dtoWithoutTitle = { + ...recipientDto, + address: { ...recipientDto.address, title: undefined }, + } as any; + cities.findCityWithProvince.mockResolvedValue(mockCity); + prisma.packageRecipient.create.mockResolvedValue({} as any); + + await service.createRecipient('user-123', dtoWithoutTitle); + + expect(prisma.packageRecipient.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + address: expect.objectContaining({ + create: expect.objectContaining({ title: 'علی احمدی' }), + }), + }), + include: { address: true }, + }); + }); + + it('should run creation inside TransactionRunner', async () => { + cities.findCityWithProvince.mockResolvedValue(mockCity); + prisma.packageRecipient.create.mockResolvedValue({} as any); + + await service.createRecipient('user-123', recipientDto); + + expect(runner.run).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAllRecipients', () => { + it('should list highlighted recipients by default', async () => { + prisma.packageRecipient.findMany.mockResolvedValue([]); + + await service.getAllRecipients('user-123'); + + expect(prisma.packageRecipient.findMany).toHaveBeenCalledWith({ + where: { + address: { userId: 'user-123' }, + isHighlighted: true, + OR: [ + { fullName: { contains: undefined, mode: 'insensitive' } }, + { + address: { title: { contains: undefined, mode: 'insensitive' } }, + }, + ], + }, + include: { address: true }, + }); + }); + + it('should search by fullName and address title', async () => { + prisma.packageRecipient.findMany.mockResolvedValue([]); + + await service.getAllRecipients('user-123', 'علی', false); + + expect(prisma.packageRecipient.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + isHighlighted: false, + OR: [ + { fullName: { contains: 'علی', mode: 'insensitive' } }, + { + address: { title: { contains: 'علی', mode: 'insensitive' } }, + }, + ], + }), + }), + ); + }); + }); +}); diff --git a/src/modules/package/domain/recipient.service.ts b/src/modules/package/domain/recipient.service.ts new file mode 100644 index 0000000..f0c2501 --- /dev/null +++ b/src/modules/package/domain/recipient.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { CityRepository } from '../../prisma/repositories/city.repository'; +import { CreateRecipientDto } from '../dto/create-recipient.dto'; + +/** + * Recipient + address management (Phase 4 Task 5). + * + * Owns `createRecipient` (city → province resolution with address creation + * in one `TransactionRunner` op) and `getAllRecipients`. No manual + * `.catch` — the global filter maps Prisma errors. + */ +@Injectable() +export class RecipientService { + constructor( + private prisma: PrismaService, + private runner: TransactionRunner, + private cities: CityRepository, + ) {} + + async createRecipient( + userId: string, + { address: { cityId, ...address }, ...recipientDto }: CreateRecipientDto, + ) { + return this.runner.run(async (tx) => { + const city = await this.cities.findCityWithProvince(cityId, tx); + + return tx.packageRecipient.create({ + data: { + ...recipientDto, + address: { + create: { + userId, + ...address, + title: address.title ?? recipientDto.fullName, + province: city.province.name, + city: city.name, + }, + }, + }, + include: { + address: true, + }, + }); + }); + } + + async getAllRecipients( + userId: string, + search?: string, + isHighlighted = true, + ) { + return this.prisma.packageRecipient.findMany({ + where: { + address: { + userId, + }, + isHighlighted, + OR: [ + { + fullName: { + contains: search, + mode: 'insensitive', + }, + }, + { + address: { + title: { + contains: search, + mode: 'insensitive', + }, + }, + }, + ], + }, + include: { + address: true, + }, + }); + } +} diff --git a/src/modules/package/domain/tracking.service.spec.ts b/src/modules/package/domain/tracking.service.spec.ts new file mode 100644 index 0000000..3186c5d --- /dev/null +++ b/src/modules/package/domain/tracking.service.spec.ts @@ -0,0 +1,121 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TrackingService } from './tracking.service'; +import { S3Service } from '../../s3/s3.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '../../../../generated/prisma'; + +describe('TrackingService', () => { + let service: TrackingService; + let prisma: DeepMockProxy; + let s3Service: DeepMockProxy; + + const mockMatchedRequest = { + id: 'matched-123', + tripId: 'trip-123', + packageId: 'package-123', + trackingCode: '17571445988911932924', + deliveryCode: '12345', + trackingUpdates: [{ city: 'Tehran', createdAt: new Date() }], + package: { + code: 1001, + sender: { + firstName: 'Ahmad', + lastName: 'Mohammadi', + phoneNumber: '+989123456789', + }, + recipient: { + address: { city: 'Tehran' }, + }, + items: ['Books'], + weight: 2000, + dimensions: '30x20x15', + finalPrice: 50000, + breakdown: {}, + status: 'delivered', + packageValue: 100000, + deliveryAtDestination: true, + }, + trip: { + transporter: { + rate: 4.5, + profilePictureKey: 'profile-pic-key', + user: { + firstName: 'Reza', + lastName: 'Ahmadi', + phoneNumber: '+989123456780', + }, + }, + vehicle: { + vehicleType: 'truck', + model: { brand: { name: 'Volvo' } }, + }, + }, + } as any; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + s3Service = mockDeep(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TrackingService, + { provide: PrismaService, useValue: prisma }, + { provide: S3Service, useValue: s3Service }, + ], + }).compile(); + + service = module.get(TrackingService); + }); + + describe('getTrackingByCode', () => { + it('should get trip tracking by code successfully', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockMatchedRequest, + ); + s3Service.generateGetPresignedUrl.mockResolvedValue( + 'https://s3.example.com/profile.jpg', + ); + + const result = await service.getTrackingByCode('TRK123456789'); + + expect(result.trackingUpdates).toBeDefined(); + expect(result.package).toBeDefined(); + expect(result.transporter).toBeDefined(); + expect(result.transporter.profilePictureUrl).toBe( + 'https://s3.example.com/profile.jpg', + ); + }); + + it('should mask the sender phone number (S-8)', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockMatchedRequest, + ); + s3Service.generateGetPresignedUrl.mockResolvedValue( + 'https://s3.example.com/profile.jpg', + ); + + const result = await service.getTrackingByCode('TRK123456789'); + + expect(result.package.sender.phoneNumber).toBe('+98•••••789'); + expect(result.package.sender.phoneNumber).not.toContain('989123456789'); + }); + + it('should query by tracking code with non-deleted filter', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockMatchedRequest, + ); + s3Service.generateGetPresignedUrl.mockResolvedValue('url'); + + await service.getTrackingByCode('TRK123456789'); + + expect(prisma.matchedRequest.findUniqueOrThrow).toHaveBeenCalledWith( + expect.objectContaining({ + where: { trackingCode: 'TRK123456789', deletedAt: null }, + }), + ); + }); + }); +}); diff --git a/src/modules/package/domain/tracking.service.ts b/src/modules/package/domain/tracking.service.ts new file mode 100644 index 0000000..387fddd --- /dev/null +++ b/src/modules/package/domain/tracking.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { S3Service } from '../../s3/s3.service'; +import { maskPhoneNumber } from '../../../common/utils/phone'; + +/** + * Public package tracking (Phase 4 Task 5). + * + * Owns `getTrackingByCode`. S-8 fix: the sender phone number is masked + * (`+98•••••123`) instead of exposed in clear — this endpoint is public + * (no auth guard). The transporter phone is left as-is (residual PII, + * follow-up). + */ +@Injectable() +export class TrackingService { + constructor( + private prisma: PrismaService, + private s3Service: S3Service, + ) {} + + async getTrackingByCode(trackingCode: string) { + const matchedRequest = await this.prisma.matchedRequest.findUniqueOrThrow({ + where: { + trackingCode, + deletedAt: null, + }, + include: { + trackingUpdates: { + orderBy: { + createdAt: 'desc', + }, + }, + package: { + select: { + code: true, + sender: { + select: { + firstName: true, + lastName: true, + phoneNumber: true, + }, + }, + recipient: { + include: { + address: true, + }, + }, + items: true, + weight: true, + dimensions: true, + finalPrice: true, + breakdown: true, + status: true, + packageValue: true, + deliveryAtDestination: true, + }, + }, + trip: { + select: { + transporter: { + select: { + profilePictureKey: true, + rate: true, + user: { + select: { + firstName: true, + lastName: true, + phoneNumber: true, + }, + }, + }, + }, + vehicle: { + select: { + vehicleType: true, + model: { + select: { + brand: true, + }, + }, + manufactureYear: true, + color: true, + }, + }, + }, + }, + }, + }); + + return { + trackingUpdates: matchedRequest.trackingUpdates, + package: { + ...matchedRequest.package, + sender: { + ...matchedRequest.package.sender, + phoneNumber: maskPhoneNumber( + matchedRequest.package.sender.phoneNumber ?? '', + ), + }, + }, + transporter: { + profilePictureUrl: await this.s3Service.generateGetPresignedUrl( + matchedRequest.trip.transporter.profilePictureKey, + ), + ...matchedRequest.trip.transporter, + ...matchedRequest.trip.transporter.user, + vehicle: matchedRequest.trip.vehicle, + }, + }; + } +} diff --git a/src/modules/package/matching.service.spec.ts b/src/modules/package/matching.service.spec.ts index 8baa936..8309523 100644 --- a/src/modules/package/matching.service.spec.ts +++ b/src/modules/package/matching.service.spec.ts @@ -5,6 +5,7 @@ import { ConfigService } from '@nestjs/config'; import { PrismaClient, TripStatusEnum } from '../../../generated/prisma'; import { TurfService } from '../turf/turf.service'; import { PrismaService } from '../prisma/prisma.service'; +import { ConfigKey } from '../../common/config/config-names'; import { PackageWithLocations, TripWithLocations, @@ -654,5 +655,115 @@ describe('MatchingService', () => { it('should handle corridor width configuration', () => { expect(service['corridorWidth']).toBe(2); }); + + it('should read corridor width via ConfigKey', () => { + expect(ConfigKey.Pricing.CorridorWidth).toBe('CORRIDOR_WIDTH'); + expect(configService.get).toHaveBeenCalledWith( + ConfigKey.Pricing.CorridorWidth, + 10, + ); + }); + + it('should default corridor width to 10 when config is missing', async () => { + const emptyConfig = mockDeep(); + emptyConfig.get.mockImplementation( + (_key: string, defaultValue?: unknown) => defaultValue, + ); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MatchingService, + { provide: ConfigService, useValue: emptyConfig }, + { provide: PrismaService, useValue: prismaService }, + { provide: TurfService, useValue: turfService }, + ], + }).compile(); + + expect( + module.get(MatchingService)['corridorWidth'], + ).toBe(10); + }); + + it('should log via Logger and skip trips that fail analysis', async () => { + const candidateTrips = [ + mockTripData, + { ...mockTripData, id: 'trip-456' }, + ]; + const validMatchResult: MatchResult = { + tripId: 'trip-123', + isRequestSent: false, + score: 400, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + }; + + jest + .spyOn(service as any, 'getPreFilteredTrips') + .mockResolvedValue(candidateTrips); + jest + .spyOn(service as any, 'analyzeTrip') + .mockResolvedValueOnce(validMatchResult) + .mockRejectedValueOnce(new Error('Analysis failed')); + const loggerSpy = jest + .spyOn((service as any).logger, 'error') + .mockImplementation(() => undefined); + const freshSession = { packages: [] } as any; + + const result = await service.findMatchedTrips( + mockPackageData, + freshSession, + 20, + prismaService, + ); + + expect(result).toEqual([validMatchResult]); + expect(loggerSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledWith( + 'Error analyzing trip trip-456:', + expect.any(Error), + ); + }); + + it('should delegate scoring to MatchingScorer without drift', () => { + expect(service['calculateMatchingScore'](2000, 2000, true)).toBe(2000); + expect(service['calculateMatchingScore'](100, 100, true)).toBe(0); + }); + + it('should use default maxResults and tx when not provided', async () => { + jest.spyOn(service as any, 'getPreFilteredTrips').mockResolvedValue([]); + + const freshSession = { packages: [] } as any; + const result = await service.findMatchedTrips( + mockPackageData, + freshSession, + ); + + expect(result).toEqual([]); + expect(service['getPreFilteredTrips']).toHaveBeenCalledWith( + mockPackageData, + undefined, + prismaService, + ); + expect(freshSession.packages[0].matchResults).toEqual([]); + }); + + it('should use configured corridor width when analyzeTrip width is omitted', async () => { + const analyzerSpy = jest + .spyOn((service as any).analyzer, 'analyzeTrip') + .mockResolvedValue(null); + + await service['analyzeTrip']( + mockTripData, + mockPackageData.originAddress, + mockPackageData.recipient.address, + ); + + expect(analyzerSpy).toHaveBeenCalledWith( + mockTripData, + mockPackageData.originAddress, + mockPackageData.recipient.address, + 2, + ); + }); }); }); diff --git a/src/modules/package/matching.service.ts b/src/modules/package/matching.service.ts index ef52c2c..db62b46 100644 --- a/src/modules/package/matching.service.ts +++ b/src/modules/package/matching.service.ts @@ -1,6 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { Prisma, TripStatusEnum } from '../../../generated/prisma'; import { Location } from '../map/map.types'; import { ConfigService } from '@nestjs/config'; import { SessionData } from 'express-session'; @@ -11,17 +10,29 @@ import { } from './matching.types'; import { PrismaTransaction } from '../prisma/prisma.types'; import { TurfService } from '../turf/turf.service'; +import { ConfigKey } from '../../common/config/config-names'; +import { MatchingScorer } from './matching/matching-scorer'; +import { CorridorAnalyzer } from './matching/corridor-analyzer'; +import { buildTripCandidateWhere } from './matching/trip-candidate-query'; @Injectable() export class MatchingService { - private corridorWidth: number; + private readonly logger = new Logger(MatchingService.name); + private readonly corridorWidth: number; + private readonly scorer: MatchingScorer; + private readonly analyzer: CorridorAnalyzer; constructor( config: ConfigService, private prisma: PrismaService, - private turfService: TurfService, + turfService: TurfService, ) { - this.corridorWidth = config.get('CORRIDOR_WIDTH', 10); + this.corridorWidth = config.get( + ConfigKey.Pricing.CorridorWidth, + 10, + ); + this.scorer = new MatchingScorer(); + this.analyzer = new CorridorAnalyzer(turfService, this.scorer); } async findMatchedTrips( @@ -61,7 +72,7 @@ export class MatchingService { packageData.recipient.address, this.corridorWidth, ).catch((error) => { - console.error(`Error analyzing trip ${trip.id}:`, error); + this.logger.error(`Error analyzing trip ${trip.id}:`, error); return null; }), ); @@ -100,25 +111,10 @@ export class MatchingService { lastCheckMatching?: Date, tx: PrismaTransaction = this.prisma, ) { - const whereClause: Prisma.TripWhereInput = { - isActive: true, - status: TripStatusEnum.scheduled, - }; - - // Just check new trips after lastCheckMatching - if (lastCheckMatching) { - whereClause.updatedAt = { - gte: lastCheckMatching, - }; - } - - // Filter by weight capacity - if (packageData.weight) { - whereClause.OR = [ - { maxPackageWeightGr: { gte: packageData.weight } }, - { maxPackageWeightGr: null }, - ]; - } + const whereClause = buildTripCandidateWhere({ + weight: packageData.weight, + lastCheckMatching, + }); // TODO: Filter by departure time @@ -161,85 +157,26 @@ export class MatchingService { packageDestination: Location, corridorWidthKm: number = this.corridorWidth, ): Promise { - const tripRoute = this.turfService.createRoute( - trip.origin, - trip.destination, - trip.waypoints, - ); - - const packageOriginPoint = this.turfService.createPoint(packageOrigin); - const packageDestinationPoint = - this.turfService.createPoint(packageDestination); - - // Calculate distances from package points to trip route - const originDistance = this.turfService.getDistanceToRoute( - packageOriginPoint, - tripRoute, + return this.analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + corridorWidthKm, ); - const destinationDistance = this.turfService.getDistanceToRoute( - packageDestinationPoint, - tripRoute, - ); - - // Check if both points are within corridor - const corridorWidthMeters = corridorWidthKm * 1000; - const isOnCorridor = - originDistance <= corridorWidthMeters && - destinationDistance <= corridorWidthMeters; - - if (!isOnCorridor) { - return null; - } - - // Check package and trip are in the same direction. - const isDirectionCompatible = this.turfService.checkDirectionCompatibility( - tripRoute, - packageOriginPoint, - packageDestinationPoint, - ); - - if (!isDirectionCompatible) { - return null; - } - - const score = this.calculateMatchingScore( - originDistance, - destinationDistance, - isOnCorridor, - ); - - return { - tripId: trip.id, - isRequestSent: false, - score, - originDistance, - destinationDistance, - isOnCorridor, - }; } // Note: Score lower is better. - // MVP version + // MVP version — kept as a thin delegate so existing callers/tests keep + // working; new code should use `MatchingScorer` directly. private calculateMatchingScore( originDistance: number, destinationDistance: number, isOnCorridor: boolean, ): number { - // Base score - let score = (originDistance + destinationDistance) / 2; - - // Lower score for packages not on corridor - if (!isOnCorridor) { - score += 100_000; - } - - // Score for trips that start/end very close to package points - if (originDistance < 1000) score -= 500; - if (destinationDistance < 1000) score -= 500; - - // TODO: Add time-based scoring (preferred times) - // TODO: Add transporter rating scoring - - return Math.max(0, score); // Ensure non-negative score + return this.scorer.calculateMatchingScore( + originDistance, + destinationDistance, + isOnCorridor, + ); } } diff --git a/src/modules/package/matching/corridor-analyzer.spec.ts b/src/modules/package/matching/corridor-analyzer.spec.ts new file mode 100644 index 0000000..4341788 --- /dev/null +++ b/src/modules/package/matching/corridor-analyzer.spec.ts @@ -0,0 +1,171 @@ +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { TurfService } from '../../turf/turf.service'; +import { Location } from '../../map/map.types'; +import { TripWithLocations } from '../matching.types'; +import { CorridorAnalyzer } from './corridor-analyzer'; +import { MatchingScorer } from './matching-scorer'; +import { createTripWithLocations } from '../../../../test/fixtures/match.fixture'; +import { createLocation } from '../../../../test/fixtures/location.fixture'; + +describe('CorridorAnalyzer', () => { + let analyzer: CorridorAnalyzer; + let turfService: DeepMockProxy; + + const mockRoute = { + type: 'LineString', + coordinates: [ + ['51.3800', '35.6850'], + ['51.3400', '35.7250'], + ], + }; + + const packageOrigin: Location = createLocation('35.6892', '51.3890'); + const packageDestination: Location = createLocation('35.7219', '51.3347'); + + beforeEach(() => { + turfService = mockDeep(); + analyzer = new CorridorAnalyzer(turfService, new MatchingScorer()); + }); + + function stubTurf( + originDistance: number, + destinationDistance: number, + directionCompatible: boolean, + trip: TripWithLocations = createTripWithLocations(), + ): TripWithLocations { + turfService.createRoute.mockReturnValue(mockRoute as never); + turfService.createPoint.mockReturnValue({ + type: 'Point', + coordinates: [0, 0], + } as never); + turfService.getDistanceToRoute + .mockReturnValueOnce(originDistance) + .mockReturnValueOnce(destinationDistance); + turfService.checkDirectionCompatibility.mockReturnValue( + directionCompatible, + ); + return trip; + } + + describe('analyzeTrip', () => { + it('should return match result for compatible trip', async () => { + const trip = stubTurf(800, 600, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toEqual({ + tripId: trip.id, + isRequestSent: false, + score: 0, + originDistance: 800, + destinationDistance: 600, + isOnCorridor: true, + }); + }); + + it('should return null when origin is outside corridor', async () => { + const trip = stubTurf(3000, 600, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should return null when destination is outside corridor', async () => { + const trip = stubTurf(800, 3000, true); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should return null when direction is incompatible', async () => { + const trip = stubTurf(800, 600, false); + + const result = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toBeNull(); + }); + + it('should create route with waypoints', async () => { + const trip = createTripWithLocations({ + waypoints: [ + createLocation('35.7000', '51.3600'), + createLocation('35.7100', '51.3500'), + ], + }); + stubTurf(800, 600, true, trip); + + await analyzer.analyzeTrip(trip, packageOrigin, packageDestination, 2); + + expect(turfService.createRoute).toHaveBeenCalledWith( + trip.origin, + trip.destination, + trip.waypoints, + ); + }); + + it('should respect custom corridor width', async () => { + const trip = stubTurf(9000, 9000, true); + + const narrow = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 2, + ); + expect(narrow).toBeNull(); + + stubTurf(9000, 9000, true, trip); + const wide = await analyzer.analyzeTrip( + trip, + packageOrigin, + packageDestination, + 10, + ); + expect(wide).not.toBeNull(); + }); + + it('should default to a working scorer when not provided', async () => { + const defaultAnalyzer = new CorridorAnalyzer(turfService); + turfService.createRoute.mockReturnValue(mockRoute as never); + turfService.createPoint.mockReturnValue({ + type: 'Point', + coordinates: [0, 0], + } as never); + turfService.getDistanceToRoute + .mockReturnValueOnce(800) + .mockReturnValueOnce(600); + turfService.checkDirectionCompatibility.mockReturnValue(true); + + const result = await defaultAnalyzer.analyzeTrip( + createTripWithLocations(), + packageOrigin, + packageDestination, + 2, + ); + + expect(result).toMatchObject({ isOnCorridor: true, score: 0 }); + }); + }); +}); diff --git a/src/modules/package/matching/corridor-analyzer.ts b/src/modules/package/matching/corridor-analyzer.ts new file mode 100644 index 0000000..7532e2b --- /dev/null +++ b/src/modules/package/matching/corridor-analyzer.ts @@ -0,0 +1,81 @@ +import { Location } from '../../map/map.types'; +import { TurfService } from '../../turf/turf.service'; +import { MatchResult, TripWithLocations } from '../matching.types'; +import { MatchingScorer } from './matching-scorer'; + +/** + * Corridor + direction analysis for one candidate trip (Phase 4 Task 3). + * + * Pure orchestration over `TurfService` geometry + `MatchingScorer`. + * Extracted verbatim from `MatchingService.analyzeTrip`; returns `null` when + * the trip is not a match (out of corridor or wrong direction). + */ +export class CorridorAnalyzer { + constructor( + private readonly turfService: TurfService, + private readonly scorer: MatchingScorer = new MatchingScorer(), + ) {} + + async analyzeTrip( + trip: TripWithLocations, + packageOrigin: Location, + packageDestination: Location, + corridorWidthKm: number, + ): Promise { + const tripRoute = this.turfService.createRoute( + trip.origin, + trip.destination, + trip.waypoints, + ); + + const packageOriginPoint = this.turfService.createPoint(packageOrigin); + const packageDestinationPoint = + this.turfService.createPoint(packageDestination); + + // Calculate distances from package points to trip route + const originDistance = this.turfService.getDistanceToRoute( + packageOriginPoint, + tripRoute, + ); + const destinationDistance = this.turfService.getDistanceToRoute( + packageDestinationPoint, + tripRoute, + ); + + // Check if both points are within corridor + const corridorWidthMeters = corridorWidthKm * 1000; + const isOnCorridor = + originDistance <= corridorWidthMeters && + destinationDistance <= corridorWidthMeters; + + if (!isOnCorridor) { + return null; + } + + // Check package and trip are in the same direction. + const isDirectionCompatible = this.turfService.checkDirectionCompatibility( + tripRoute, + packageOriginPoint, + packageDestinationPoint, + ); + + if (!isDirectionCompatible) { + return null; + } + + const score = this.scorer.calculateMatchingScore( + originDistance, + destinationDistance, + isOnCorridor, + ); + + return { + tripId: trip.id, + isRequestSent: false, + score, + originDistance, + destinationDistance, + isOnCorridor, + }; + } +} diff --git a/src/modules/package/matching/matching-scorer.spec.ts b/src/modules/package/matching/matching-scorer.spec.ts new file mode 100644 index 0000000..5667800 --- /dev/null +++ b/src/modules/package/matching/matching-scorer.spec.ts @@ -0,0 +1,55 @@ +import { MatchingScorer } from './matching-scorer'; + +describe('MatchingScorer', () => { + const scorer = new MatchingScorer(); + + describe('calculateMatchingScore', () => { + it.each([ + // [originDistance, destinationDistance, isOnCorridor, expected] + [800, 600, true, 0], + [500, 800, true, 0], + [500, 500, true, 0], + [100, 100, true, 0], + [2000, 2000, true, 2000], + [1500, 1500, true, 1500], + [5000, 1000, true, 3000], + [800, 600, false, 99700], + [2000, 2000, false, 102000], + ])( + 'should score (%p, %p, onCorridor=%p) as %p', + (origin, destination, isOnCorridor, expected) => { + expect( + scorer.calculateMatchingScore(origin, destination, isOnCorridor), + ).toBe(expected); + }, + ); + + it('should add penalty for trips not on corridor when far', () => { + const score = scorer.calculateMatchingScore(800, 600, false); + expect(score).toBe(99700); + }); + + it('should apply single close-point bonus when only origin is close', () => { + // (500 + 2000) / 2 - 500 = 750 + expect(scorer.calculateMatchingScore(500, 2000, true)).toBe(750); + }); + + it('should apply single close-point bonus when only destination is close', () => { + // (2000 + 500) / 2 - 500 = 750 + expect(scorer.calculateMatchingScore(2000, 500, true)).toBe(750); + }); + + it('should apply no bonus when both points are far', () => { + expect(scorer.calculateMatchingScore(2000, 3000, true)).toBe(2500); + }); + + it('should ensure non-negative scores', () => { + expect(scorer.calculateMatchingScore(100, 100, true)).toBe(0); + }); + + it('should treat exactly 1000m as not close (strict <)', () => { + // (1000 + 1000) / 2 = 1000, no bonus + expect(scorer.calculateMatchingScore(1000, 1000, true)).toBe(1000); + }); + }); +}); diff --git a/src/modules/package/matching/matching-scorer.ts b/src/modules/package/matching/matching-scorer.ts new file mode 100644 index 0000000..083d33d --- /dev/null +++ b/src/modules/package/matching/matching-scorer.ts @@ -0,0 +1,39 @@ +/** + * Pure matching score calculator (Phase 4 Task 3). + * + * Extracted verbatim from `MatchingService.calculateMatchingScore` so the + * orchestration service can delegate without behavior drift. + * + * Note: lower score is better (MVP rules). + */ +export const MATCHING_OFF_CORRIDOR_PENALTY = 100_000; +export const MATCHING_CLOSE_POINT_THRESHOLD_M = 1000; +export const MATCHING_CLOSE_POINT_BONUS = 500; + +export class MatchingScorer { + calculateMatchingScore( + originDistance: number, + destinationDistance: number, + isOnCorridor: boolean, + ): number { + // Base score: mean distance to route. + let score = (originDistance + destinationDistance) / 2; + + // Penalty for packages not on corridor (unreachable in practice today + // because the analyzer filters first — kept for parity + unit coverage). + if (!isOnCorridor) { + score += MATCHING_OFF_CORRIDOR_PENALTY; + } + + // Bonus for trips that start/end very close to package points. + if (originDistance < MATCHING_CLOSE_POINT_THRESHOLD_M) + score -= MATCHING_CLOSE_POINT_BONUS; + if (destinationDistance < MATCHING_CLOSE_POINT_THRESHOLD_M) + score -= MATCHING_CLOSE_POINT_BONUS; + + // TODO: picks up time-based scoring (preferred times) — MVP gap. + // TODO: picks up transporter rating scoring — MVP gap. + + return Math.max(0, score); // Ensure non-negative score + } +} diff --git a/src/modules/package/matching/trip-candidate-query.spec.ts b/src/modules/package/matching/trip-candidate-query.spec.ts new file mode 100644 index 0000000..5e89188 --- /dev/null +++ b/src/modules/package/matching/trip-candidate-query.spec.ts @@ -0,0 +1,70 @@ +import { TripStatusEnum } from '../../../../generated/prisma'; +import { buildTripCandidateWhere } from './trip-candidate-query'; + +describe('buildTripCandidateWhere', () => { + describe('base filter', () => { + it('should return only active scheduled filter when no input', () => { + expect(buildTripCandidateWhere({})).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + + it('should not filter by weight when weight is null', () => { + expect(buildTripCandidateWhere({ weight: null })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + + it('should not filter by weight when weight is zero', () => { + expect(buildTripCandidateWhere({ weight: 0 })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + }); + }); + }); + + describe('lastCheckMatching', () => { + it('should filter by updatedAt when lastCheckMatching is provided', () => { + const lastCheck = new Date('2024-01-15T10:00:00Z'); + + expect(buildTripCandidateWhere({ lastCheckMatching: lastCheck })).toEqual( + { + isActive: true, + status: TripStatusEnum.scheduled, + updatedAt: { gte: lastCheck }, + }, + ); + }); + }); + + describe('weight capacity', () => { + it('should filter by weight capacity with OR-null when weight is present', () => { + expect(buildTripCandidateWhere({ weight: 5000 })).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + OR: [ + { maxPackageWeightGr: { gte: 5000 } }, + { maxPackageWeightGr: null }, + ], + }); + }); + + it('should combine lastCheckMatching and weight filters', () => { + const lastCheck = new Date('2024-01-15T10:00:00Z'); + + expect( + buildTripCandidateWhere({ weight: 2000, lastCheckMatching: lastCheck }), + ).toEqual({ + isActive: true, + status: TripStatusEnum.scheduled, + updatedAt: { gte: lastCheck }, + OR: [ + { maxPackageWeightGr: { gte: 2000 } }, + { maxPackageWeightGr: null }, + ], + }); + }); + }); +}); diff --git a/src/modules/package/matching/trip-candidate-query.ts b/src/modules/package/matching/trip-candidate-query.ts new file mode 100644 index 0000000..efea4e2 --- /dev/null +++ b/src/modules/package/matching/trip-candidate-query.ts @@ -0,0 +1,40 @@ +import { Prisma, TripStatusEnum } from '../../../../generated/prisma'; + +/** + * Pure pre-filter query builder (Phase 4 Task 3). + * + * Extracted verbatim from `MatchingService.getPreFilteredTrips` so the where + * clause can be unit-tested without Prisma. + */ +export interface TripCandidateQueryInput { + weight?: number | null; + lastCheckMatching?: Date; +} + +export function buildTripCandidateWhere( + input: TripCandidateQueryInput, +): Prisma.TripWhereInput { + const whereClause: Prisma.TripWhereInput = { + isActive: true, + status: TripStatusEnum.scheduled, + }; + + // Just check new trips after lastCheckMatching + if (input.lastCheckMatching) { + whereClause.updatedAt = { + gte: input.lastCheckMatching, + }; + } + + // Filter by weight capacity + if (input.weight) { + whereClause.OR = [ + { maxPackageWeightGr: { gte: input.weight } }, + { maxPackageWeightGr: null }, + ]; + } + + // TODO: picks up departure-time filtering — MVP gap, no behavior change. + + return whereClause; +} diff --git a/src/modules/package/package.controller.spec.ts b/src/modules/package/package.controller.spec.ts index fb1d786..93252ab 100644 --- a/src/modules/package/package.controller.spec.ts +++ b/src/modules/package/package.controller.spec.ts @@ -1,25 +1,33 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PackageController } from './package.controller'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { PackageService } from './package.service'; -import { MatchingService } from './matching.service'; +import { PackageService } from './application/package.service'; +import { RecipientService } from './domain/recipient.service'; +import { PackageRequestService } from './domain/package-request.service'; +import { TrackingService } from './domain/tracking.service'; import { AccessTokenGuard } from '../auth/guard/token.guard'; import { OwnershipGuard } from '../auth/guard/ownership.guard'; describe('PackageController', () => { let controller: PackageController; let service: DeepMockProxy; - let matchingService: DeepMockProxy; + let recipientService: DeepMockProxy; + let requestService: DeepMockProxy; + let trackingService: DeepMockProxy; beforeEach(async () => { service = mockDeep(); - matchingService = mockDeep(); + recipientService = mockDeep(); + requestService = mockDeep(); + trackingService = mockDeep(); const module: TestingModule = await Test.createTestingModule({ controllers: [PackageController], providers: [ { provide: PackageService, useValue: service }, - { provide: MatchingService, useValue: matchingService }, + { provide: RecipientService, useValue: recipientService }, + { provide: PackageRequestService, useValue: requestService }, + { provide: TrackingService, useValue: trackingService }, ], }) .overrideGuard(AccessTokenGuard) @@ -34,4 +42,36 @@ describe('PackageController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('should delegate recipient routes to RecipientService', async () => { + await controller.createPackageRecipient({ fullName: 'A' } as any, 'user-1'); + expect(recipientService.createRecipient).toHaveBeenCalledWith('user-1', { + fullName: 'A', + }); + }); + + it('should delegate request routes to PackageRequestService', async () => { + const session = { packages: [] } as any; + await controller.createTripRequest( + { packageId: 'p', tripId: 't' } as any, + 'user-1', + session, + ); + expect(requestService.createRequest).toHaveBeenCalledWith( + 'user-1', + { packageId: 'p', tripId: 't' }, + session, + ); + + await controller.updateTripRequest('request-1', session); + expect(requestService.updateRequest).toHaveBeenCalledWith( + 'request-1', + session, + ); + }); + + it('should delegate tracking route to TrackingService', async () => { + await controller.getTripTrackingByCode('TRK123'); + expect(trackingService.getTrackingByCode).toHaveBeenCalledWith('TRK123'); + }); }); diff --git a/src/modules/package/package.controller.ts b/src/modules/package/package.controller.ts index 8993feb..35ce759 100644 --- a/src/modules/package/package.controller.ts +++ b/src/modules/package/package.controller.ts @@ -14,7 +14,10 @@ import { Session, UseGuards, } from '@nestjs/common'; -import { PackageService } from './package.service'; +import { PackageService } from './application/package.service'; +import { RecipientService } from './domain/recipient.service'; +import { PackageRequestService } from './domain/package-request.service'; +import { TrackingService } from './domain/tracking.service'; import { CreateRecipientDto } from './dto/create-recipient.dto'; import { AccessTokenGuard } from '../auth/guard/token.guard'; import { @@ -55,7 +58,12 @@ import { TrackingResponseDto } from './dto/tracking-response.dto'; @Controller('packages') export class PackageController { - constructor(private packageService: PackageService) {} + constructor( + private packageService: PackageService, + private recipientService: RecipientService, + private requestService: PackageRequestService, + private trackingService: TrackingService, + ) {} @ApiOperation({ summary: 'Create a new recipient', @@ -77,7 +85,7 @@ export class PackageController { @Body() body: CreateRecipientDto, @CurrentUser('id') id: string, ) { - return this.packageService.createRecipient(id, body); + return this.recipientService.createRecipient(id, body); } @ApiOperation({ @@ -95,7 +103,7 @@ export class PackageController { @CurrentUser('id') id: string, @Query('search') search?: string, ) { - return this.packageService.getAllRecipients(id, search); + return this.recipientService.getAllRecipients(id, search); } @ApiOperation({ @@ -258,7 +266,7 @@ export class PackageController { @CurrentUser('id') userId: string, @Session() session: SessionData, ) { - return this.packageService.createRequest(userId, body, session); + return this.requestService.createRequest(userId, body, session); } @ApiOperation({ @@ -282,7 +290,7 @@ export class PackageController { @Query('status', new ParseEnumPipe(RequestStatusEnum, { optional: true })) status?: RequestStatusEnum, ) { - return this.packageService.getAllPackageRequests( + return this.requestService.getAllPackageRequests( id, status ? [status] : undefined, ); @@ -306,7 +314,7 @@ export class PackageController { @Param('id', ParseUUIDPipe) id: string, @Session() session: SessionData, ) { - return this.packageService.updateRequest(id, session); + return this.requestService.updateRequest(id, session); } @ApiOperation({ @@ -319,6 +327,6 @@ export class PackageController { @Serialize(TrackingResponseDto) @Get('tracking/:code') async getTripTrackingByCode(@Param('code') code: string) { - return this.packageService.getTrackingByCode(code); + return this.trackingService.getTrackingByCode(code); } } diff --git a/src/modules/package/package.module.ts b/src/modules/package/package.module.ts index c1ccb26..9dbffd9 100644 --- a/src/modules/package/package.module.ts +++ b/src/modules/package/package.module.ts @@ -1,5 +1,8 @@ import { Module } from '@nestjs/common'; -import { PackageService } from './package.service'; +import { PackageService } from './application/package.service'; +import { RecipientService } from './domain/recipient.service'; +import { PackageRequestService } from './domain/package-request.service'; +import { TrackingService } from './domain/tracking.service'; import { PackageController } from './package.controller'; import { PrismaModule } from '../prisma/prisma.module'; import { TokenModule } from '../token/token.module'; @@ -24,7 +27,13 @@ import { NotificationModule } from '../notification/notification.module'; TurfModule, NotificationModule, ], - providers: [PackageService, MatchingService], + providers: [ + PackageService, + MatchingService, + RecipientService, + PackageRequestService, + TrackingService, + ], controllers: [PackageController], }) export class PackageModule { diff --git a/src/modules/package/package.service.spec.ts b/src/modules/package/package.service.spec.ts deleted file mode 100644 index 9c86a59..0000000 --- a/src/modules/package/package.service.spec.ts +++ /dev/null @@ -1,939 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PackageService } from './package.service'; -import { MatchingService } from './matching.service'; -import { TripService } from '../trip/trip.service'; -import { S3Service } from '../s3/s3.service'; -import { TurfService } from '../turf/turf.service'; -import { PricingService } from '../pricing/pricing.service'; -import { MapService } from '../map/map.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { - PrismaClient, - PackageStatusEnum, - RequestStatusEnum, - TripStatusEnum, -} from '../../../generated/prisma'; -import { - BadRequestException, - ForbiddenException, - NotFoundException, -} from '@nestjs/common'; -import { - AuthMessages, - BadRequestMessages, - NotFoundMessages, -} from '../../common/enums/messages.enum'; -import { CreateRecipientDto } from './dto/create-recipient.dto'; -import { CreatePackageDto } from './dto/create-package.dto'; -import { UpdatePackageDto } from './dto/update.package.dto'; -import { CreateRequestDto } from '../trip/dto/create-request.dto'; -import * as utilities from '../../common/utilities'; -import { NotificationService } from '../notification/notification.service'; - -jest.mock('../../common/utilities', () => ({ - formatPrismaError: jest.fn(), -})); - -describe('PackageService', () => { - let service: PackageService; - let prismaService: DeepMockProxy; - let mapService: DeepMockProxy; - let pricingService: DeepMockProxy; - let matchingService: DeepMockProxy; - let tripService: DeepMockProxy; - let s3Service: DeepMockProxy; - let turfService: DeepMockProxy; - let notificationService: DeepMockProxy; - - const mockCity = { - id: 'city-123', - name: 'تهران', - province: { - id: 'province-123', - name: 'تهران', - }, - } as any; - - const mockOriginAddress = { - id: 'address-123', - userId: 'user-123', - title: 'خانه', - latitude: 35.6892, - longitude: 51.389, - city: 'تهران', - province: 'تهران', - } as any; - - const mockRecipient = { - id: 'recipient-123', - fullName: 'علی احمدی', - phoneNumber: '+989123456788', - address: { - userId: 'user-123', - title: 'دفتر کار', - city: 'تهران', - province: 'تهران', - street: 'خیابان ولیعصر', - details: 'روبروی ساختمان قدیم', - latitude: '35.7219', - longitude: '51.3347', - postalCode: '1234567890', - }, - } as any; - - const mockPackage = { - id: 'package-123', - senderId: 'user-123', - items: ['کتاب', 'لپ‌تاپ'], - weight: 2000, - dimensions: '30x20x15', - status: PackageStatusEnum.created, - suggestedPrice: 50000, - finalPrice: 50000, - isFragile: false, - isPerishable: false, - pickupAtOrigin: true, - deliveryAtDestination: true, - picturesKey: ['pic1.jpg', 'pic2.jpg'], - originAddress: mockOriginAddress, - recipient: mockRecipient, - deletedAt: null, - } as any; - - const mockTrip = { - id: 'trip-123', - status: TripStatusEnum.scheduled, - origin: { latitude: 35.6892, longitude: 51.389 }, - destination: { latitude: 35.7219, longitude: 51.3347 }, - waypoints: [], - normalDistanceKm: 10, - normalDurationMin: 30, - totalDeviationDurationMin: 0, - matchedRequests: [], - } as any; - - const mockBreakdown = { - basePrice: 20000, - distanceCost: 15000, - weightCost: 10000, - specialHandlingCost: 0, - cityPremiumCost: 5000, - }; - - const mockMatchedRequest = { - id: 'matched-123', - tripId: 'trip-123', - packageId: 'package-123', - trackingCode: '17571445988911932924', - deliveryCode: '12345', - transporterNotes: [], - pickupTime: null, - deliveryTime: null, - package: { - id: 'package-123', - status: PackageStatusEnum.matched, - senderId: 'user-123', - originAddress: { - latitude: '35.6892', - longitude: '51.3890', - city: 'Tehran', - }, - recipient: { - address: { - latitude: '35.7219', - longitude: '51.3347', - city: 'Tehran', - }, - }, - breakdown: { baseCost: 100000, deviationCost: 0 }, - }, - trip: { - status: TripStatusEnum.in_progress, - transporter: { - rate: 4.5, - rateCount: 10, - profilePictureKey: 'profile-pic-key', - user: { - firstName: 'Ahmad', - lastName: 'Mohammadi', - phoneNumber: '+989123456789', - }, - }, - vehicle: { - vehicleType: 'truck', - model: { brand: { name: 'Volvo' } }, - }, - }, - } as any; - - beforeEach(async () => { - jest.resetAllMocks(); - - prismaService = mockDeep(); - mapService = mockDeep(); - pricingService = mockDeep(); - matchingService = mockDeep(); - tripService = mockDeep(); - s3Service = mockDeep(); - turfService = mockDeep(); - notificationService = mockDeep(); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PackageService, - { provide: PrismaService, useValue: prismaService }, - { provide: MapService, useValue: mapService }, - { provide: PricingService, useValue: pricingService }, - { provide: MatchingService, useValue: matchingService }, - { provide: TripService, useValue: tripService }, - { provide: S3Service, useValue: s3Service }, - { provide: TurfService, useValue: turfService }, - { provide: NotificationService, useValue: notificationService }, - ], - }).compile(); - - service = module.get(PackageService); - }); - - describe('createRecipient', () => { - const recipientDto: CreateRecipientDto = { - fullName: 'علی احمدی', - phoneNumber: '+989123456788', - address: { - cityId: 'city-123', - title: 'دفتر کار', - street: 'خیابان ولیعصر', - details: 'روبروی ساختمان قدیم', - latitude: '35.7219', - longitude: '51.3347', - postalCode: '1234567890', - }, - isHighlighted: true, - }; - - it('should create recipient successfully', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.city.findUniqueOrThrow.mockResolvedValue(mockCity); - prismaService.packageRecipient.create.mockResolvedValue(mockRecipient); - - const result = await service.createRecipient('user-123', recipientDto); - - expect(result).toEqual(mockRecipient); - expect(prismaService.city.findUniqueOrThrow).toHaveBeenCalledWith({ - where: { id: 'city-123' }, - include: { province: true }, - }); - expect(prismaService.packageRecipient.create).toHaveBeenCalledWith({ - data: { - fullName: 'علی احمدی', - isHighlighted: true, - phoneNumber: '+989123456788', - address: { - create: { - userId: 'user-123', - title: 'دفتر کار', - city: 'تهران', - province: 'تهران', - street: 'خیابان ولیعصر', - details: 'روبروی ساختمان قدیم', - latitude: '35.7219', - longitude: '51.3347', - postalCode: '1234567890', - }, - }, - }, - include: { address: true }, - }); - }); - - it('should handle Prisma errors', async () => { - const error = new Error('DB error'); - prismaService.$transaction.mockRejectedValue(error); - (utilities.formatPrismaError as unknown as jest.Mock).mockImplementation( - () => { - throw new Error('Formatted error'); - }, - ); - - await expect( - service.createRecipient('user-123', recipientDto), - ).rejects.toThrow('Formatted error'); - }); - }); - - describe('getAllRecipients', () => { - it('should return highlighted recipients', async () => { - const recipients = [mockRecipient]; - prismaService.packageRecipient.findMany.mockResolvedValue(recipients); - - const result = await service.getAllRecipients('user-123'); - - expect(result).toEqual(recipients); - expect(prismaService.packageRecipient.findMany).toHaveBeenCalledWith({ - where: { - address: { userId: 'user-123' }, - isHighlighted: true, - OR: [ - { - fullName: { - contains: undefined, - mode: 'insensitive', - }, - }, - { - address: { - title: { - contains: undefined, - mode: 'insensitive', - }, - }, - }, - ], - }, - include: { address: true }, - }); - }); - - it('should filter by search term', async () => { - const recipients = [mockRecipient]; - prismaService.packageRecipient.findMany.mockResolvedValue(recipients); - - await service.getAllRecipients('user-123', 'علی'); - - expect(prismaService.packageRecipient.findMany).toHaveBeenCalledWith({ - where: { - address: { userId: 'user-123' }, - isHighlighted: true, - OR: [ - { - fullName: { - contains: 'علی', - mode: 'insensitive', - }, - }, - { - address: { - title: { - contains: 'علی', - mode: 'insensitive', - }, - }, - }, - ], - }, - include: { address: true }, - }); - }); - }); - - describe('create', () => { - const packageDto: CreatePackageDto = { - items: ['کتاب'], - originAddressId: 'address-123', - recipientId: 'recipient-123', - weight: 1500, - dimensions: '25x20x10', - isFragile: false, - isPerishable: false, - pickupAtOrigin: true, - deliveryAtDestination: true, - picturesKey: ['pic1.jpg'], - } as any; - - it('should create package successfully', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.address.findFirst.mockResolvedValue(mockOriginAddress); - prismaService.packageRecipient.findFirst.mockResolvedValue(mockRecipient); - prismaService.package.create.mockResolvedValue(mockPackage); - - mapService.calculateDistance.mockResolvedValue({ - distance: 15, - duration: 25, - }); - pricingService.calculateSuggestedPrice.mockReturnValue({ - suggestedPrice: 50000, - breakdown: mockBreakdown, - }); - - const result = await service.create('user-123', packageDto); - - expect(result).toEqual(mockPackage); - expect(mapService.calculateDistance).toHaveBeenCalledWith({ - origin: { - latitude: mockOriginAddress.latitude, - longitude: mockOriginAddress.longitude, - }, - destination: { - latitude: mockRecipient.address.latitude, - longitude: mockRecipient.address.longitude, - }, - }); - expect(pricingService.calculateSuggestedPrice).toHaveBeenCalled(); - }); - - it('should throw when origin address not found', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.address.findFirst.mockResolvedValue(null); - - await expect(service.create('user-123', packageDto)).rejects.toThrow( - new ForbiddenException( - `${AuthMessages.EntityAccessDenied} origin address.`, - ), - ); - }); - - it('should throw when recipient not found', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.address.findFirst.mockResolvedValue(mockOriginAddress); - prismaService.packageRecipient.findFirst.mockResolvedValue(null); - - await expect(service.create('user-123', packageDto)).rejects.toThrow( - new ForbiddenException(`${AuthMessages.EntityAccessDenied} recipient.`), - ); - }); - }); - - describe('getById', () => { - it('should return package with presigned URLs', async () => { - const packageWithUrls = { - ...mockPackage, - picturesKey: ['pic1.jpg', 'pic2.jpg'], - picturesUrl: ['url1', 'url2'], - }; - - prismaService.package.findFirstOrThrow.mockResolvedValue(mockPackage); - s3Service.generateGetPresignedUrl - .mockResolvedValueOnce('url1') - .mockResolvedValueOnce('url2'); - - const result = await service.getById('package-123'); - - expect(result).toMatchObject(packageWithUrls); - }); - // TODO: fix - // it('should handle S3 URL generation errors', async () => { - // prismaService.package.findFirstOrThrow.mockResolvedValue(mockPackage); - - // s3Service.generateGetPresignedUrl.mockReset(); - // s3Service.generateGetPresignedUrl - // .mockRejectedValue(new Error('S3 error')) - // .mockResolvedValueOnce('url2'); - - // const result = await service.getById('package-123'); - - // expect(result.picturesKey).toEqual({ - // keys: ['pic1.jpg', 'pic2.jpg'], - // presignedUrls: ['', 'url2'] - // }); - // }); - }); - - describe('update', () => { - const updateDto: UpdatePackageDto = { - dimensions: '35x25x20', - finalPrice: 60000, - }; - - it('should update package successfully', async () => { - const updatedPackage = { ...mockPackage, ...updateDto }; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.created, - suggestedPrice: 50000, - } as any); - prismaService.package.update.mockResolvedValue(updatedPackage); - - const result = await service.update('package-123', updateDto); - - expect(result).toEqual(updatedPackage); - expect(prismaService.package.update).toHaveBeenCalledWith({ - where: { id: 'package-123' }, - data: updateDto, - }); - }); - - it('should throw when package status is invalid', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.matched, - suggestedPrice: 50000, - } as any); - - await expect(service.update('package-123', updateDto)).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BasePackageStatus} ${PackageStatusEnum.matched}.`, - ), - ); - }); - - it('should throw when final price is below suggested price', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.created, - suggestedPrice: 50000, - } as any); - - await expect( - service.update('package-123', { finalPrice: 40000 }), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.InvalidPrice), - ); - }); - }); - - describe('delete', () => { - it('should soft delete package successfully', async () => { - const deletedPackage = { ...mockPackage, deletedAt: new Date() }; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.created, - } as any); - prismaService.package.update.mockResolvedValue(deletedPackage); - - const result = await service.delete('package-123'); - - expect(result.deletedAt).toBeDefined(); - expect(prismaService.package.update).toHaveBeenCalledWith({ - where: { id: 'package-123' }, - data: { deletedAt: expect.any(Date) }, - }); - }); - - it('should throw when package status is invalid', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findFirstOrThrow.mockResolvedValue({ - status: PackageStatusEnum.matched, - } as any); - - await expect(service.delete('package-123')).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BasePackageStatus} ${PackageStatusEnum.matched}.`, - ), - ); - }); - }); - - describe('getMatchedTrips', () => { - const session = { - packages: [], - } as any; - - it('should return matched trips with deviation info', async () => { - const matchResults = [ - { - tripId: 'trip-123', - isRequestSent: false, - score: 100, - originDistance: 500, - destinationDistance: 300, - isOnCorridor: true, - }, - ]; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - service.getById = jest.fn().mockResolvedValue(mockPackage); - matchingService.findMatchedTrips.mockResolvedValue(matchResults); - tripService.getMultipleById.mockResolvedValue([mockTrip]); - turfService.sortLocationsByRoute.mockReturnValue([] as any); - mapService.calculateDistance.mockResolvedValue({ - distance: 12, - duration: 35, - }); - pricingService.calculateDeviationCost.mockReturnValue(5000); - - const result = await service.getMatchedTrips('package-123', session); - - expect(result).toHaveLength(1); - expect(result[0]?.additionalPrice).toBe(5000); - }); - - it('should throw when package status is invalid', async () => { - const invalidPackage = { - ...mockPackage, - status: PackageStatusEnum.matched, - }; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - service.getById = jest.fn().mockResolvedValue(invalidPackage); - - await expect( - service.getMatchedTrips('package-123', session), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.SendRequestPackage), - ); - }); - }); - - describe('createRequest', () => { - const requestDto: CreateRequestDto = { - packageId: 'package-123', - tripId: 'trip-123', - senderNote: 'لطفا با دقت حمل کنید', - }; - - const session = { - packages: [ - { - id: 'package-123', - matchResults: [ - { - tripId: 'trip-123', - isRequestSent: false, - score: 100, - originDistance: 500, - destinationDistance: 300, - isOnCorridor: true, - deviationInfo: { - distance: 2, - duration: 5, - additionalPrice: 3000, - }, - }, - ], - }, - ], - } as any; - - it('should create request successfully', async () => { - const createdRequest = { - id: 'request-123', - packageId: 'package-123', - tripId: 'trip-123', - status: RequestStatusEnum.pending, - offeredPrice: 45000, - deviationCost: 3000, - }; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - status: PackageStatusEnum.searching_transporter, - }); - prismaService.trip.findUniqueOrThrow.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.scheduled, - }); - prismaService.tripRequest.create.mockResolvedValue(createdRequest as any); - pricingService.calculateTransporterEarnings.mockReturnValue(42000); - - const result = await service.createRequest( - 'user-123', - requestDto, - session, - ); - - expect(result).toEqual(createdRequest); - expect(session.packages[0].matchResults[0].isRequestSent).toBe(true); - }); - - it('should throw when user does not own package', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - senderId: 'other-user', - }); - - await expect( - service.createRequest('user-123', requestDto, session), - ).rejects.toThrow( - new ForbiddenException(`${AuthMessages.EntityAccessDenied} package.`), - ); - }); - - it('should throw when package status is invalid', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - status: PackageStatusEnum.matched, - }); - - await expect( - service.createRequest('user-123', requestDto, session), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.SendRequestPackage), - ); - }); - - it('should throw when trip status is invalid', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - status: PackageStatusEnum.searching_transporter, - }); - prismaService.trip.findUniqueOrThrow.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.completed, - }); - - await expect( - service.createRequest('user-123', requestDto, session), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.SendRequestTrip), - ); - }); - - it('should throw when no matched trips found', async () => { - const emptySession = { - packages: [], - } as any; - - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - prismaService.package.findUniqueOrThrow.mockResolvedValue({ - ...mockPackage, - status: PackageStatusEnum.searching_transporter, - }); - prismaService.trip.findUniqueOrThrow.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.scheduled, - }); - - await expect( - service.createRequest('user-123', requestDto, emptySession), - ).rejects.toThrow(new NotFoundException(NotFoundMessages.MatchedTrip)); - }); - }); - - describe('getAllPackageRequests', () => { - it('should return all package requests', async () => { - const requests = [ - { - id: 'request-1', - packageId: 'package-123', - status: RequestStatusEnum.pending, - }, - { - id: 'request-2', - packageId: 'package-123', - status: RequestStatusEnum.accepted, - }, - ]; - - prismaService.tripRequest.findMany.mockResolvedValue(requests as any); - - const result = await service.getAllPackageRequests('package-123'); - - expect(result).toEqual(requests); - expect(prismaService.tripRequest.findMany).toHaveBeenCalledWith({ - where: { - packageId: 'package-123', - status: { - in: Object.values(RequestStatusEnum), - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - }); - - it('should filter by status', async () => { - const pendingRequests = [ - { - id: 'request-1', - packageId: 'package-123', - status: RequestStatusEnum.pending, - }, - ]; - - prismaService.tripRequest.findMany.mockResolvedValue( - pendingRequests as any, - ); - - await service.getAllPackageRequests('package-123', [ - RequestStatusEnum.pending, - ]); - - expect(prismaService.tripRequest.findMany).toHaveBeenCalledWith({ - where: { - packageId: 'package-123', - status: { - in: [RequestStatusEnum.pending], - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - }); - }); - - describe('updateRequest', () => { - it('should cancel request and update session', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - const canceledRequest = { - id: 'request-123', - packageId: 'package-123', - tripId: 'trip-123', - status: RequestStatusEnum.canceled, - }; - - const session = { - packages: [ - { - id: 'package-123', - matchResults: [ - { - tripId: 'trip-123', - isRequestSent: true, - score: 100, - originDistance: 500, - destinationDistance: 300, - isOnCorridor: true, - }, - ], - }, - ], - } as any; - - prismaService.tripRequest.update.mockResolvedValue({ - ...canceledRequest, - package: { id: 'package-123', senderId: 'user-123' }, - } as any); - - const result = await service.updateRequest('request-123', session); - - expect(result).toEqual(canceledRequest); - expect(session.packages[0].matchResults[0].isRequestSent).toBe(false); - expect(prismaService.tripRequest.update).toHaveBeenCalledWith({ - where: { - id: 'request-123', - status: RequestStatusEnum.pending, - }, - data: { - status: RequestStatusEnum.canceled, - }, - include: { - package: { - select: { - id: true, - senderId: true, - }, - }, - }, - }); - }); - - it('should handle missing session data gracefully', async () => { - prismaService.$transaction.mockImplementation(async (callback) => - callback(prismaService), - ); - const canceledRequest = { - id: 'request-123', - packageId: 'package-456', - tripId: 'trip-789', - status: RequestStatusEnum.canceled, - }; - - const session = { - packages: [], - } as any; - - prismaService.tripRequest.update.mockResolvedValue({ - ...canceledRequest, - package: { id: 'package-456', senderId: 'user-456' }, - } as any); - - const result = await service.updateRequest('request-123', session); - - expect(result).toEqual(canceledRequest); - }); - }); - - describe('getTripTrackingByCode', () => { - it('should get trip tracking by code successfully', async () => { - const matchedRequestWithTracking = { - ...mockMatchedRequest, - trackingUpdates: [{ city: 'Tehran', createdAt: new Date() }], - }; - - prismaService.matchedRequest.findUniqueOrThrow.mockResolvedValue( - matchedRequestWithTracking, - ); - s3Service.generateGetPresignedUrl.mockResolvedValue( - 'https://s3.example.com/profile.jpg', - ); - - const result = await service.getTrackingByCode('TRK123456789'); - - expect(result.trackingUpdates).toBeDefined(); - expect(result.package).toBeDefined(); - expect(result.transporter).toBeDefined(); - expect(result.transporter.profilePictureUrl).toBe( - 'https://s3.example.com/profile.jpg', - ); - }); - }); - - describe('Error scenarios', () => { - it('should handle Prisma errors in getAll', async () => { - const error = new Error('DB error'); - prismaService.package.findMany.mockRejectedValue(error); - (utilities.formatPrismaError as unknown as jest.Mock).mockImplementation( - () => { - throw new Error('Formatted error'); - }, - ); - - await expect(service.getAll('user-123')).rejects.toThrow( - 'Formatted error', - ); - }); - - it('should handle empty picturesKey array', async () => { - const packageWithoutPics = { ...mockPackage, picturesKey: [] }; - prismaService.package.findMany.mockResolvedValue([packageWithoutPics]); - - const result = await service.getAll('user-123'); - - expect(result).toBeDefined(); - }); - - it('should handle null picturesKey', async () => { - const packageWithNullPics = { ...mockPackage, picturesKey: null }; - prismaService.package.findFirstOrThrow.mockResolvedValue( - packageWithNullPics, - ); - - const result = await service.getById('package-123'); - - expect(result.picturesKey).toBeNull(); - }); - }); -}); diff --git a/src/modules/package/package.service.ts b/src/modules/package/package.service.ts deleted file mode 100644 index 1d6e80e..0000000 --- a/src/modules/package/package.service.ts +++ /dev/null @@ -1,846 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; -import { CreateRecipientDto } from './dto/create-recipient.dto'; -import { CreatePackageDto } from './dto/create-package.dto'; -import { - AuthMessages, - BadRequestMessages, - NotFoundMessages, -} from '../../common/enums/messages.enum'; -import { formatPrismaError } from '../../common/utilities'; -import { UpdatePackageDto } from './dto/update.package.dto'; -import { - PackageStatusEnum, - RequestStatusEnum, - TripStatusEnum, -} from '../../../generated/prisma'; -import { MapService } from '../map/map.service'; -import { PricingService } from '../pricing/pricing.service'; -import { S3Service } from '../s3/s3.service'; -import { SessionData } from 'express-session'; -import { MatchingService } from './matching.service'; -import { TripService } from '../trip/trip.service'; -import { PrismaTransaction } from '../prisma/prisma.types'; -import { JsonArray } from '../../../generated/prisma/runtime/library'; -import { CreateRequestDto } from '../trip/dto/create-request.dto'; -import { instanceToPlain } from 'class-transformer'; -import { TurfService } from '../turf/turf.service'; -import { NotificationService } from '../notification/notification.service'; -import { - getNotificationMessage, - NotificationMessages, -} from '../notification/notification-messages'; - -@Injectable() -export class PackageService { - constructor( - private prisma: PrismaService, - private mapService: MapService, - private pricingService: PricingService, - private matchingService: MatchingService, - private tripService: TripService, - private s3Service: S3Service, - private turfService: TurfService, - private notificationService: NotificationService, - ) {} - - async createRecipient( - userId: string, - { address: { cityId, ...address }, ...recipientDto }: CreateRecipientDto, - ) { - return this.prisma - .$transaction(async (tx) => { - const city = await tx.city.findUniqueOrThrow({ - where: { id: cityId }, - include: { - province: true, - }, - }); - - return tx.packageRecipient.create({ - data: { - ...recipientDto, - address: { - create: { - userId, - ...address, - title: address.title ?? recipientDto.fullName, - province: city.province.name, - city: city.name, - }, - }, - }, - include: { - address: true, - }, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAllRecipients( - userId: string, - search?: string, - isHighlighted = true, - ) { - return this.prisma.packageRecipient - .findMany({ - where: { - address: { - userId, - }, - isHighlighted, - OR: [ - { - fullName: { - contains: search, - mode: 'insensitive', - }, - }, - { - address: { - title: { - contains: search, - mode: 'insensitive', - }, - }, - }, - ], - }, - include: { - address: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async create( - userId: string, - { items, originAddressId, recipientId, ...packageDto }: CreatePackageDto, - ) { - return this.prisma - .$transaction(async (tx) => { - const originAddress = await tx.address.findFirst({ - where: { - id: originAddressId, - userId, - }, - }); - - if (!originAddress) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} origin address.`, - ); - } - - const recipient = await tx.packageRecipient.findFirst({ - where: { - id: recipientId, - address: { - userId, - }, - }, - include: { - address: true, - }, - }); - - if (!recipient) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} recipient.`, - ); - } - - // Calculate distance for pricing - const { distance } = await this.mapService.calculateDistance({ - origin: { - latitude: originAddress.latitude, - longitude: originAddress.longitude, - }, - destination: { - latitude: recipient.address.latitude, - longitude: recipient.address.longitude, - }, - }); - - const { suggestedPrice, breakdown } = - this.pricingService.calculateSuggestedPrice({ - distanceKm: distance, - weightGr: packageDto.weight, - isFragile: packageDto.isFragile ?? false, - isPerishable: packageDto.isPerishable ?? false, - originCity: originAddress.city!, - destinationCity: recipient.address.city!, - }); - - const plainBreakdown = instanceToPlain(breakdown); - - const packageData = await tx.package.create({ - data: { - senderId: userId, - items, - originAddressId: originAddress.id, - recipientId: recipient.id, - ...packageDto, - suggestedPrice, - breakdown: plainBreakdown, - finalPrice: suggestedPrice, - }, - include: { - originAddress: true, - recipient: { - include: { - address: true, - }, - }, - }, - }); - - // Add create package notification - await this.notificationService.create( - userId, - { - packageId: packageData.id, - content: getNotificationMessage( - NotificationMessages.PackageCreated, - { packageCode: packageData.code }, - ), - }, - tx, - ); - - return packageData; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getById(id: string, tx: PrismaTransaction = this.prisma) { - const packageData = await tx.package - .findFirstOrThrow({ - where: { - id, - deletedAt: null, - }, - include: { - originAddress: true, - recipient: { - include: { - address: true, - }, - }, - matchedRequest: { - select: { - trip: { - select: { - transporter: { - select: { - rate: true, - user: { - select: { - firstName: true, - lastName: true, - gender: true, - phoneNumber: true, - }, - }, - }, - }, - vehicle: { - select: { - vehicleType: true, - model: { - select: { - brand: true, - }, - }, - manufactureYear: true, - color: true, - }, - }, - departureTime: true, - status: true, - description: true, - }, - }, - trackingCode: true, - deliveryCode: true, - transporterNotes: true, - senderRating: true, - senderComment: true, - isCompleted: true, - pickupTime: true, - deliveryTime: true, - paymentStatus: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - const keys = packageData.picturesKey; - let picturesUrl: string[] | undefined; - if (packageData.picturesKey && Array.isArray(packageData.picturesKey)) { - picturesUrl = await this.generatePackagePicPresignedUrl(keys as any); - } - - return { - ...packageData, - picturesUrl, - }; - } - - async getAll( - userId: string, - status: PackageStatusEnum[] = [ - PackageStatusEnum.created, - PackageStatusEnum.searching_transporter, - PackageStatusEnum.matched, - PackageStatusEnum.picked_up, - PackageStatusEnum.in_transit, - ], - page = 1, - limit = 10, - ) { - const skip = (page - 1) * limit; - const packages = await this.prisma.package - .findMany({ - where: { - senderId: userId, - status: { - in: status, - }, - deletedAt: null, - }, - include: { - originAddress: true, - recipient: { - include: { - address: true, - }, - }, - }, - orderBy: { - createdAt: 'desc', - }, - skip, - take: limit, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - return Promise.all( - packages.map(async (packageData) => { - const keys = packageData.picturesKey; - if (keys && Array.isArray(keys)) { - return { - ...packageData, - picturesUrl: await this.generatePackagePicPresignedUrl(keys), - picturesKey: undefined, - }; - } - }), - ); - } - - async update(id: string, packageDto: UpdatePackageDto) { - return this.prisma - .$transaction(async (tx) => { - const { status, suggestedPrice } = await tx.package.findFirstOrThrow({ - where: { - id, - deletedAt: null, - }, - select: { - status: true, - suggestedPrice: true, - }, - }); - - const isValidStatus = - status === PackageStatusEnum.created || - status === PackageStatusEnum.searching_transporter; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus} ${status}.`, - ); - } - - if (packageDto.finalPrice < suggestedPrice) { - throw new BadRequestException(BadRequestMessages.InvalidPrice); - } - - return this.prisma.package.update({ - where: { id }, - data: packageDto, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async generatePackagePicPresignedUrl(keys: JsonArray) { - return Promise.all( - keys.map(async (key, index) => { - const keyString = JSON.stringify(key).split('"')[1]; - try { - if (keyString) { - return this.s3Service.generateGetPresignedUrl(keyString); - } - return ''; - } catch (urlError) { - console.error( - `Failed to generate presigned URL for picturesKey[${index}]:`, - urlError, - ); - return ''; - } - }), - ); - } - - private async updateStatus( - id: string, - status: PackageStatusEnum, - tx: PrismaTransaction = this.prisma, - ) { - return tx.package.update({ - where: { - id, - deletedAt: null, - }, - data: { - status, - }, - }); - } - - async delete(id: string) { - return this.prisma - .$transaction(async (tx) => { - const { status } = await tx.package.findFirstOrThrow({ - where: { id }, - select: { status: true }, - }); - - const isValidStatus = - status === PackageStatusEnum.created || - status === PackageStatusEnum.searching_transporter; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus} ${status}.`, - ); - } - - return this.prisma.package.update({ - where: { id }, - data: { - deletedAt: new Date(), - }, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getMatchedTrips( - packageId: string, - session: SessionData, - maxResults = 20, - ) { - return this.prisma.$transaction(async (tx) => { - const packageData = await this.getById(packageId, tx); - - const idValidPackageStatus = - packageData.status === PackageStatusEnum.created || - packageData.status === PackageStatusEnum.searching_transporter || - packageData.status === PackageStatusEnum.cancelled; - if (!idValidPackageStatus) { - throw new BadRequestException(BadRequestMessages.SendRequestPackage); - } - - // Update package status - await this.updateStatus( - packageId, - PackageStatusEnum.searching_transporter, - tx, - ); - - // Do matching - const matchedTrips = await this.matchingService.findMatchedTrips( - packageData, - session, - maxResults, - tx, - ); - - // Fetch trips - const tripIds = matchedTrips - .filter((m) => !m.isRequestSent) - .slice(0, maxResults) - .map((m) => m.tripId); - const trips = await this.tripService.getMultipleById(tripIds, tx); - const tripMap = new Map(trips.map((trip) => [trip.id, trip])); - - // Calculate deviation info - const matchingResult = await Promise.all( - matchedTrips.map(async (matchedTrip) => { - const trip = tripMap.get(matchedTrip.tripId); - if (!trip) return; - - const waypoints = trip.matchedRequests - .flatMap((r) => [ - r.package.pickupAtOrigin - ? { - latitude: r.package.originAddress.latitude, - longitude: r.package.originAddress.longitude, - } - : undefined, - r.package.deliveryAtDestination - ? { - latitude: r.package.recipient.address.latitude, - longitude: r.package.recipient.address.longitude, - } - : undefined, - ]) - .filter((v) => v !== undefined); - - waypoints.push( - ...[ - packageData.pickupAtOrigin - ? { - latitude: packageData.originAddress.latitude, - longitude: packageData.originAddress.longitude, - } - : undefined, - packageData.deliveryAtDestination - ? { - latitude: packageData.recipient.address.latitude, - longitude: packageData.recipient.address.longitude, - } - : undefined, - ].filter((v) => v !== undefined), - ); - - const sortedWaypoints = this.turfService.sortLocationsByRoute( - trip.origin, - trip.destination, - waypoints, - ); - - const { distance, duration } = - await this.mapService.calculateDistance({ - origin: { - latitude: trip.origin.latitude, - longitude: trip.origin.longitude, - }, - destination: { - latitude: trip.destination.latitude, - longitude: trip.destination.longitude, - }, - waypoints: sortedWaypoints, - }); - - const deviationDistance = Math.max( - 0, - distance - - ((trip.normalDistanceKm ?? 0) + - (trip.totalDeviationDurationMin ?? 0)), - ); - const deviationDuration = Math.max( - 0, - duration - - ((trip.normalDurationMin ?? 0) + - (trip.totalDeviationDurationMin ?? 0)), - ); - - const additionalPrice = this.pricingService.calculateDeviationCost( - deviationDistance, - deviationDuration, - ); - matchedTrip.deviationInfo = { - distance: deviationDistance, - duration: deviationDuration, - additionalPrice, - }; - - return { - ...trip, - additionalPrice, - }; - }), - ); - - return matchingResult.filter(Boolean); - }); - } - - async createRequest( - userId: string, - { packageId, tripId, senderNote }: CreateRequestDto, - session: SessionData, - ) { - return this.prisma - .$transaction(async (tx) => { - const packageData = await tx.package.findUniqueOrThrow({ - where: { id: packageId }, - }); - - if (userId !== packageData.senderId) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} package.`, - ); - } - - if (packageData.status !== PackageStatusEnum.searching_transporter) { - throw new BadRequestException(BadRequestMessages.SendRequestPackage); - } - - const tripData = await tx.trip.findUniqueOrThrow({ - where: { id: tripId }, - include: { - origin: true, - destination: true, - waypoints: true, - }, - }); - - if (tripData.status !== TripStatusEnum.scheduled) { - throw new BadRequestException(BadRequestMessages.SendRequestTrip); - } - - const matchedTrips = session.packages.find( - (p) => p.id === packageId, - )?.matchResults; - if (!matchedTrips || !matchedTrips.length) { - throw new NotFoundException(NotFoundMessages.MatchedTrip); - } - - const matchedTrip = matchedTrips.find((t) => t.tripId === tripId); - if (!matchedTrip) { - throw new BadRequestException(BadRequestMessages.SendRequestTrip); - } - - const deviationDistance = matchedTrip.deviationInfo?.distance ?? 0; - const deviationDuration = matchedTrip.deviationInfo?.duration ?? 0; - const deviationCost = matchedTrip.deviationInfo?.additionalPrice ?? 0; - const transporterEarnings = - this.pricingService.calculateTransporterEarnings( - packageData.finalPrice, - deviationCost, - ); - const offeredPrice = transporterEarnings + deviationCost; - - const request = await tx.tripRequest.create({ - data: { - packageId, - tripId, - deviationDistanceKm: deviationDistance, - deviationDurationMin: deviationDuration, - deviationCost, - offeredPrice, - senderNote, - }, - }); - - // Update session - matchedTrip.isRequestSent = true; - - // Add create request notification - await this.notificationService.create( - userId, - { - packageId, - content: getNotificationMessage( - NotificationMessages.TripRequestCreated, - { - packageCode: packageData.code, - tripCode: tripData.code, - }, - ), - }, - tx, - ); - - return request; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAllPackageRequests( - packageId: string, - status: RequestStatusEnum[] = Object.values(RequestStatusEnum), - ) { - return this.prisma.tripRequest.findMany({ - where: { - packageId, - status: { - in: status, - }, - }, - orderBy: { - createdAt: 'desc', - }, - }); - } - - async updateRequest(requestId: string, session: SessionData) { - return this.prisma - .$transaction(async (tx) => { - const { package: packageData, ...request } = - await tx.tripRequest.update({ - where: { - id: requestId, - status: RequestStatusEnum.pending, - }, - data: { - status: RequestStatusEnum.canceled, - }, - include: { - package: { - select: { - id: true, - senderId: true, - }, - }, - }, - }); - - // Update session - const matchedTrip = session.packages - .find((p) => p.id === request.packageId) - ?.matchResults.find((m) => m.tripId === request.tripId); - - if (matchedTrip) matchedTrip.isRequestSent = false; - - // Add create package notification - await this.notificationService.create( - packageData.senderId, - { - packageId: packageData.id, - content: NotificationMessages.TripRequestCanceled, - }, - tx, - ); - - return request; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getTrackingByCode(trackingCode: string) { - const matchedRequest = await this.prisma.matchedRequest - .findUniqueOrThrow({ - where: { - trackingCode, - deletedAt: null, - }, - include: { - trackingUpdates: { - orderBy: { - createdAt: 'desc', - }, - }, - package: { - select: { - code: true, - sender: { - select: { - firstName: true, - lastName: true, - phoneNumber: true, - }, - }, - recipient: { - include: { - address: true, - }, - }, - items: true, - weight: true, - dimensions: true, - finalPrice: true, - breakdown: true, - status: true, - packageValue: true, - deliveryAtDestination: true, - }, - }, - trip: { - select: { - transporter: { - select: { - profilePictureKey: true, - rate: true, - user: { - select: { - firstName: true, - lastName: true, - phoneNumber: true, - }, - }, - }, - }, - vehicle: { - select: { - vehicleType: true, - model: { - select: { - brand: true, - }, - }, - manufactureYear: true, - color: true, - }, - }, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - return { - trackingUpdates: matchedRequest.trackingUpdates, - package: matchedRequest.package, - transporter: { - profilePictureUrl: await this.s3Service.generateGetPresignedUrl( - matchedRequest.trip.transporter.profilePictureKey, - ), - ...matchedRequest.trip.transporter, - ...matchedRequest.trip.transporter.user, - vehicle: matchedRequest.trip.vehicle, - }, - }; - } -} diff --git a/src/modules/pricing/pricing.engine.spec.ts b/src/modules/pricing/pricing.engine.spec.ts new file mode 100644 index 0000000..0540895 --- /dev/null +++ b/src/modules/pricing/pricing.engine.spec.ts @@ -0,0 +1,165 @@ +import { PricingEngine } from './pricing.engine'; +import { buildPricingStrategies } from './strategies/pricing-factory'; +import { PricingInput } from './pricing.types'; + +// Persian city names kept as escapes so the spec file stays ASCII on disk +// while still exercising the exact values the engine is configured with. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; +const TABRIZ = '\u062A\u0628\u0631\u06CC\u0632'; + +/** Compact pricing-input builder. */ +const T = (d: number, w: number, o: string, to: string, f = false, p = false): PricingInput => ({ + distanceKm: d, + weightGr: w, + isFragile: f, + isPerishable: p, + originCity: o, + destinationCity: to, +}); + +/** Breakdown builder with the legacy deviationCost: 0 field. */ +const B = ( + basePrice: number, + distanceCost: number, + weightCost: number, + specialHandlingCost: number, + cityPremiumCost: number, +) => ({ + basePrice, + distanceCost, + weightCost, + deviationCost: 0, + specialHandlingCost, + cityPremiumCost, +}); + +/** Golden case builder. */ +const C = ( + label: string, + input: PricingInput, + suggestedPrice: number, + distanceCost: number, + breakdown: ReturnType, +): GoldenCase => ({ label, input, suggestedPrice, distanceCost, breakdown }); + +interface GoldenCase { + label: string; + input: PricingInput; + suggestedPrice: number; + distanceCost: number; + breakdown: ReturnType; +} + +/** + * Golden values captured verbatim from the pre-refactor PricingService + * (bit-exact floats preserved). The full 5-variant capture was validated in a + * separate parity harness; the default variant is embedded below. + */ + +const GOLDEN_PRICE_CASES: GoldenCase[] = [ + C("base", T(100, 2, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("fragile", T(100, 2, TEHRAN, ISFAHAN, true, false), 213000, 120000, B(50000, 120000, 0, 53250, 0)), + C("perishable", T(100, 2, TEHRAN, ISFAHAN, false, true), 230000, 120000, B(50000, 120000, 0, 80500.00000000001, 0)), + C("both-flags", T(100, 2, TEHRAN, ISFAHAN, true, true), 255000, 120000, B(50000, 120000, 0, 127500, 0)), + C("tiny-distance-zero-weight", T(0.5, 0, TEHRAN, TEHRAN), 51000, 600, B(50000, 600, 0, 0, 0)), + C("weight-499", T(100, 499, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("weight-500", T(100, 500, TEHRAN, ISFAHAN), 220000, 120000, B(50000, 120000, 50000, 0, 0)), + C("weight-1000", T(100, 1000, TEHRAN, ISFAHAN), 270000, 120000, B(50000, 120000, 100000, 0, 0)), + C("weight-2750", T(100, 2750, TEHRAN, ISFAHAN), 445000, 120000, B(50000, 120000, 275000, 0, 0)), + C("city-nonmajor-dest", T(1, 0, TEHRAN, KARAJ), 46000, 1200, B(50000, 1200, 0, 0, 0)), + C("city-major-origin", T(100, 0, TEHRAN, KARAJ), 153000, 120000, B(50000, 120000, 0, 0, 0)), + C("city-major-dest", T(100, 0, KARAJ, TEHRAN), 221000, 120000, B(50000, 120000, 0, 0, 66300.00000000001)), + C("city-small-both", T(100, 0, KARAJ, SHIRAZ), 204000, 120000, B(50000, 120000, 0, 0, 40799.99999999999)), + C("city-both-major", T(100, 0, TEHRAN, TEHRAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("km-50.5", T(50.5, 0, TEHRAN, ISFAHAN), 111000, 60600, B(50000, 60600, 0, 0, 0)), + C("km-100", T(100, 0, TEHRAN, ISFAHAN), 170000, 120000, B(50000, 120000, 0, 0, 0)), + C("km-101", T(101, 0, TEHRAN, ISFAHAN), 171000, 121150, B(50000, 121150, 0, 0, 0)), + C("km-150", T(150, 0, TEHRAN, ISFAHAN), 228000, 177500, B(50000, 177500, 0, 0, 0)), + C("km-300", T(300, 0, TEHRAN, ISFAHAN), 400000, 350000, B(50000, 350000, 0, 0, 0)), + C("km-301", T(301, 0, TEHRAN, ISFAHAN), 401000, 351050, B(50000, 351050, 0, 0, 0)), + C("km-600", T(600, 0, TEHRAN, ISFAHAN), 715000, 665000, B(50000, 665000, 0, 0, 0)), + C("km-601", T(601, 0, TEHRAN, ISFAHAN), 716000, 665950, B(50000, 665950, 0, 0, 0)), + C("km-1000", T(1000, 0, TEHRAN, ISFAHAN), 1095000, 1045000, B(50000, 1045000, 0, 0, 0)), + C("km-1001", T(1001, 0, TEHRAN, ISFAHAN), 1096000, 1045800, B(50000, 1045800, 0, 0, 0)), + C("km-1500", T(1500, 0, TEHRAN, ISFAHAN), 1495000, 1445000, B(50000, 1445000, 0, 0, 0)), + C("km-265-multitier", T(265, 0, TEHRAN, ISFAHAN), 360000, 309750, B(50000, 309750, 0, 0, 0)), + C("precision-150.5-2750-both", T(150.5, 2750, TEHRAN, ISFAHAN, true, true), 755000, 178075, B(50000, 178075, 275000, 377500, 0)), + C("precision-355.7-1234-perishable", T(355.7, 1234, ISFAHAN, KARAJ, false, true), 707000, 408485, B(50000, 408485, 123400, 247450.00000000006, 0)), + C("zero-everything", T(0, 0, TEHRAN, ISFAHAN), 50000, 0, B(50000, 0, 0, 0, 0)), + C("tier5-heavy", T(2500, 10000, MASHHAD, TEHRAN), 3295000, 2245000, B(50000, 2245000, 1000000, 0, 0)), +]; + +const GOLDEN_EARNINGS: Array<[string, number, number | undefined, number]> = [ + ["no-deviation", 100000, undefined, 70000], + ["with-deviation", 100000, 5000, 75000], + ["zero-price", 0, undefined, 0], + ["fractional", 123456.78, undefined, 86419], + ["fractional-with-deviation", 99999.99, 1234.56, 71234], +]; + +const GOLDEN_DEVIATION: Array<[string, number, number, number]> = [ + ["zero", 0, 0, 0], + ["typical", 5, 10, 125000], + ["fractional", 12.5, 7.5, 225000], + ["time-only", 0, 30, 150000], + ["km-only", 100, 0, 1500000], +]; + +/** Engine wired with the legacy default configuration. */ +function makeEngine(): PricingEngine { + const config = { + get: (key: string, def?: unknown) => { + const overrides: Record = { + PRICING_BASE_PRICE: 50000, + PRICING_FUEL_RATE: 200, + PRICING_WEIGHT_BASE_RATE: 10000, + PRICING_DRIVER_SHARE: 0.7, + PRICING_FRAGILE_MULTIPLIER: 1.25, + PRICING_PERISHABLE_MULTIPLIER: 1.35, + PRICING_BOTH_FRAGILE_PERISHABLE: 1.5, + PRICING_MAJOR_CITY_ORIGIN: 0.9, + PRICING_MAJOR_CITY_DESTINATION: 1.3, + PRICING_BOTH_MAJOR_CITIES: 1.0, + PRICING_SMALL_CITY_FACTOR: 1.2, + PRICING_DEVIATION_RATE: 15000, + PRICING_TIME_DEVIATION_RATE: 5000, + PRICING_MAJOR_CITIES: TEHRAN + ',' + ISFAHAN + ',' + MASHHAD, + PRICING_TIER_1_RATE: 1000, + PRICING_TIER_2_RATE: 950, + PRICING_TIER_3_RATE: 850, + PRICING_TIER_4_RATE: 750, + PRICING_TIER_5_RATE: 600, + }; + return key in overrides ? overrides[key] : def; + }, + }; + return new PricingEngine(buildPricingStrategies(config as never)); +} + +describe('PricingEngine (bit-for-bit legacy parity, default variant)', () => { + it.each(GOLDEN_PRICE_CASES.map((c) => [c.label, c]))( + 'suggested price + breakdown for %s matches the legacy capture', + (_label, c) => { + const engine = makeEngine(); + expect(engine.calculateSuggestedPrice(c.input)).toEqual({ + suggestedPrice: c.suggestedPrice, + breakdown: c.breakdown, + }); + expect(engine.calculateDistanceCost(c.input.distanceKm)).toBe( + c.distanceCost, + ); + }, + ); + + it.each(GOLDEN_EARNINGS)('transporter earnings for %s', (_label, finalPrice, deviationPrice, expected) => { + expect(makeEngine().calculateTransporterEarnings(finalPrice, deviationPrice)).toBe(expected); + }); + + it.each(GOLDEN_DEVIATION)('deviation cost for %s', (_label, km, minutes, expected) => { + expect(makeEngine().calculateDeviationCost(km, minutes)).toBe(expected); + }); +}); diff --git a/src/modules/pricing/pricing.engine.ts b/src/modules/pricing/pricing.engine.ts new file mode 100644 index 0000000..e482206 --- /dev/null +++ b/src/modules/pricing/pricing.engine.ts @@ -0,0 +1,77 @@ +import { PricingInput, PricingResult } from './pricing.types'; + +import { PricingStrategies } from './strategies/pricing-factory'; + +/** + * Pure pricing pipeline extracted verbatim from the legacy `PricingService`. + * Every arithmetic expression keeps its original order and form so results + * are bit-for-bit identical (proven against a golden capture of the legacy + * implementation across 5 configuration variants — see pricing-golden.json). + */ +export class PricingEngine { + constructor(private readonly strategies: PricingStrategies) {} + + calculateSuggestedPrice(input: PricingInput): PricingResult { + const { basePrice } = this.strategies; + const distanceCost = this.strategies.distanceTierStrategy.calculate( + input.distanceKm, + ); + const weightCost = this.strategies.weightStrategy.calculate(input.weightGr); + + // Subtotal before multipliers, then special handling, then city premium — + // multiplication order matters for float parity with the legacy engine. + let subtotal = basePrice + distanceCost + weightCost; + + const specialMultiplier = + this.strategies.specialHandlingStrategy.calculate( + input.isFragile ?? false, + input.isPerishable ?? false, + ); + subtotal *= specialMultiplier; + + const cityPremium = this.strategies.cityPremiumStrategy.calculate( + input.originCity, + input.destinationCity, + ); + subtotal *= cityPremium; + + // Legacy rounding: nearest 1000 IRR. + const finalPrice = Math.round(subtotal / 1000) * 1000; + + return { + suggestedPrice: Math.floor(finalPrice), + breakdown: { + basePrice, + distanceCost, + weightCost, + deviationCost: 0, + specialHandlingCost: Math.max(0, specialMultiplier - 1) * finalPrice, + cityPremiumCost: Math.max(0, cityPremium - 1) * finalPrice, + }, + }; + } + + calculateTransporterEarnings( + finalPrice: number, + deviationPrice?: number, + ): number { + return this.strategies.earningsStrategy.calculate( + finalPrice, + deviationPrice, + ); + } + + calculateDeviationCost( + additionalKm: number, + additionalMinutes: number, + ): number { + return this.strategies.deviationCostStrategy.calculate( + additionalKm, + additionalMinutes, + ); + } + + calculateDistanceCost(distanceKm: number): number { + return this.strategies.distanceTierStrategy.calculate(distanceKm); + } +} \ No newline at end of file diff --git a/src/modules/pricing/pricing.service.spec.ts b/src/modules/pricing/pricing.service.spec.ts index 49ed8c0..dd44670 100644 --- a/src/modules/pricing/pricing.service.spec.ts +++ b/src/modules/pricing/pricing.service.spec.ts @@ -1,548 +1,112 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; -import { PricingService } from './pricing.service'; -import { PricingInput } from './pricing.types'; -import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; - -describe('PricingService', () => { - let service: PricingService; - let configService: DeepMockProxy; - const defaultConfig = { - PRICING_BASE_PRICE: 50000, - PRICING_FUEL_RATE: 200, - PRICING_WEIGHT_BASE_RATE: 10000, - PRICING_DRIVER_SHARE: 0.7, - PRICING_FRAGILE_MULTIPLIER: 1.25, - PRICING_PERISHABLE_MULTIPLIER: 1.35, - PRICING_BOTH_FRAGILE_PERISHABLE: 1.5, - PRICING_MAJOR_CITY_ORIGIN: 0.9, - PRICING_MAJOR_CITY_DESTINATION: 1.3, - PRICING_BOTH_MAJOR_CITIES: 1.0, - PRICING_SMALL_CITY_FACTOR: 1.2, - PRICING_DEVIATION_RATE: 15000, - PRICING_TIME_DEVIATION_RATE: 5000, - PRICING_MAJOR_CITIES: 'تهران,اصفهان,مشهد', - PRICING_TIER_1_RATE: 1000, - PRICING_TIER_2_RATE: 950, - PRICING_TIER_3_RATE: 850, - PRICING_TIER_4_RATE: 750, - PRICING_TIER_5_RATE: 600, - }; +import { PricingService } from './pricing.service'; - const mockPricingInput: PricingInput = { - distanceKm: 100, - weightGr: 2, - isFragile: false, - isPerishable: false, - originCity: 'تهران', - destinationCity: 'اصفهان', +// Persian city names kept as escapes so the spec file stays ASCII on disk. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; + +/** Minimal ConfigService stand-in backed by a plain record. */ +function makeService( + overrides: Record = {}, +): PricingService { + const config = { + get: (key: string, def?: unknown) => + key in overrides ? overrides[key] : def, }; + return new PricingService(config as unknown as ConfigService); +} - beforeEach(async () => { - jest.resetAllMocks(); - - configService = mockDeep(); - - configService.get.mockImplementation((key: string, defaultValue?: any) => { - return defaultConfig[key] || defaultValue; - }); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - service = module.get(PricingService); - }); - - describe('calculateSuggestedPrice', () => { - it('should calculate basic price for normal package', async () => { - const input: PricingInput = { +describe('PricingService', () => { + it('returns the legacy suggested-price result shape (golden base case)', () => { + expect( + makeService().calculateSuggestedPrice({ distanceKm: 100, weightGr: 2, isFragile: false, isPerishable: false, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - - const result = service.calculateSuggestedPrice(input); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown).toEqual({ + originCity: TEHRAN, + destinationCity: ISFAHAN, + }), + ).toEqual({ + suggestedPrice: 170000, + breakdown: { basePrice: 50000, - distanceCost: expect.any(Number), - weightCost: expect.any(Number), + distanceCost: 120000, + weightCost: 0, deviationCost: 0, specialHandlingCost: 0, - cityPremiumCost: 0, // Both major cities - }); - }); - - it('should apply fragile multiplier', async () => { - const normalInput = { ...mockPricingInput, isFragile: false }; - const fragileInput = { ...mockPricingInput, isFragile: true }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const fragileResult = service.calculateSuggestedPrice(fragileInput); - - expect(fragileResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(fragileResult.breakdown.specialHandlingCost).toBeGreaterThan(0); - }); - - it('should apply perishable multiplier', async () => { - const normalInput = { ...mockPricingInput, isPerishable: false }; - const perishableInput = { ...mockPricingInput, isPerishable: true }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const perishableResult = service.calculateSuggestedPrice(perishableInput); - - expect(perishableResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(perishableResult.breakdown.specialHandlingCost).toBeGreaterThan(0); + cityPremiumCost: 0, + }, }); + }); - it('should apply both fragile and perishable multiplier', async () => { - const normalInput = { - ...mockPricingInput, - isFragile: false, - isPerishable: false, - }; - const fragileInput = { - ...mockPricingInput, - isFragile: true, - isPerishable: false, - }; - const bothInput = { - ...mockPricingInput, + it('delegates a flag combination to the engine (both flags)', () => { + expect( + makeService().calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, isFragile: true, isPerishable: true, - }; - - const normalResult = service.calculateSuggestedPrice(normalInput); - const fragileResult = service.calculateSuggestedPrice(fragileInput); - const bothResult = service.calculateSuggestedPrice(bothInput); - - expect(bothResult.suggestedPrice).toBeGreaterThan( - fragileResult.suggestedPrice, - ); - expect(bothResult.suggestedPrice).toBeGreaterThan( - normalResult.suggestedPrice, - ); - expect(bothResult.breakdown.specialHandlingCost).toBeGreaterThan( - fragileResult.breakdown.specialHandlingCost, - ); - }); - - it('should calculate weight cost correctly', async () => { - const lightInput = { ...mockPricingInput, weightGr: 400 }; - const heavyInput = { ...mockPricingInput, weightGr: 5000 }; - - const lightResult = service.calculateSuggestedPrice(lightInput); - const heavyResult = service.calculateSuggestedPrice(heavyInput); - - expect(lightResult.breakdown.weightCost).toBe(0); - expect(heavyResult.breakdown.weightCost).toBeGreaterThan(0); - expect(heavyResult.suggestedPrice).toBeGreaterThan( - lightResult.suggestedPrice, - ); - }); - - it('should apply city premium for major to small city', async () => { - const majorToMajor = { - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - const majorToSmall = { - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'کرج', - }; - - const majorToMajorResult = service.calculateSuggestedPrice(majorToMajor); - const majorToSmallResult = service.calculateSuggestedPrice(majorToSmall); - - expect(majorToSmallResult.suggestedPrice).toBeLessThan( - majorToMajorResult.suggestedPrice, - ); - }); - - it('should apply city premium for small to major city', async () => { - const smallToMajor = { - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'تهران', - }; - const smallToSmall = { - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'قزوین', - }; - - const smallToMajorResult = service.calculateSuggestedPrice(smallToMajor); - const smallToSmallResult = service.calculateSuggestedPrice(smallToSmall); - - expect(smallToMajorResult.suggestedPrice).toBeGreaterThan( - smallToSmallResult.suggestedPrice, - ); - expect(smallToMajorResult.breakdown.cityPremiumCost).toBeGreaterThan(0); - }); - - it('should round final price to nearest thousand', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - expect(result.suggestedPrice % 1000).toBe(0); - }); - }); - - describe('calculateDistanceCost', () => { - it('should calculate cost for tier 1 distance (0-100km)', async () => { - const cost = service.calculateDistanceCost(50); - const expectedCost = 200 * 50 + 50 * 1000; // fuel + tier rate - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 2 distance (101-300km)', async () => { - const cost = service.calculateDistanceCost(200); - const expectedCost = - 200 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 100 * defaultConfig.PRICING_TIER_2_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 3 distance (301-600km)', async () => { - const cost = service.calculateDistanceCost(400); - const expectedCost = - 400 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 100 * defaultConfig.PRICING_TIER_3_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 4 distance (601-1000km)', async () => { - const cost = service.calculateDistanceCost(800); - const expectedCost = - 800 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 300 * defaultConfig.PRICING_TIER_3_RATE + - 200 * defaultConfig.PRICING_TIER_4_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should calculate cost for tier 5 distance (1001km+)', async () => { - const cost = service.calculateDistanceCost(1200); - const expectedCost = - 1200 * defaultConfig.PRICING_FUEL_RATE + - 100 * defaultConfig.PRICING_TIER_1_RATE + - 200 * defaultConfig.PRICING_TIER_2_RATE + - 300 * defaultConfig.PRICING_TIER_3_RATE + - 400 * defaultConfig.PRICING_TIER_4_RATE + - 200 * defaultConfig.PRICING_TIER_5_RATE; - - expect(cost).toBe(expectedCost); - }); - - it('should handle zero distance', async () => { - const cost = service.calculateDistanceCost(0); - - expect(cost).toBe(0); - }); - }); - - describe('calculateTransporterEarnings', () => { - it('should calculate transporter earnings without deviation', async () => { - const finalPrice = 100000; - const earnings = service.calculateTransporterEarnings(finalPrice); - - expect(earnings).toBe(Math.floor(100000 * 0.7)); // 70000 - }); - - it('should calculate transporter earnings with deviation', async () => { - const finalPrice = 100000; - const deviationPrice = 20000; - const earnings = service.calculateTransporterEarnings( - finalPrice, - deviationPrice, - ); - - expect(earnings).toBe(Math.floor(100000 * 0.7) + 20000); // 90000 - }); - - it('should handle zero final price', async () => { - const earnings = service.calculateTransporterEarnings(0); - - expect(earnings).toBe(0); - }); + originCity: TEHRAN, + destinationCity: ISFAHAN, + }).suggestedPrice, + ).toBe(255000); }); - describe('calculateDeviationCost', () => { - it('should calculate deviation cost for distance only', async () => { - const cost = service.calculateDeviationCost(10, 0); - - expect(cost).toBe(10 * 15000); // 150000 - }); - - it('should calculate deviation cost for time only', async () => { - const cost = service.calculateDeviationCost(0, 30); + it('rounds suggested prices to the nearest 1000 IRR', () => { + // Default config: 50000 + 120000 (distance 100) = 170000; two small + // cities ×1.205 → raw subtotal 204850. Nearest-1000 rounding gives + // 205000; a truncating implementation would return 204000 instead. + const service = makeService({ PRICING_SMALL_CITY_FACTOR: 1.205 }); - expect(cost).toBe(30 * 5000); // 150000 + const result = service.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: KARAJ, + destinationCity: SHIRAZ, }); - it('should calculate combined deviation cost', async () => { - const cost = service.calculateDeviationCost(5, 20); - - expect(cost).toBe(5 * 15000 + 20 * 5000); // 175000 - }); - - it('should handle zero deviation', async () => { - const cost = service.calculateDeviationCost(0, 0); - - expect(cost).toBe(0); - }); + expect(result.suggestedPrice % 1000).toBe(0); + expect(result.suggestedPrice).toBe(205000); }); - describe('City premium calculations', () => { - it('should identify major cities correctly', async () => { - const majorToMajor = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); - - expect(majorToMajor.breakdown.cityPremiumCost).toBe(0); // Both major cities - }); - - it('should handle case insensitive city names', async () => { - const upperCase = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); + it('calculates transporter earnings with legacy floor semantics', () => { + const service = makeService(); - const mixedCase = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'تهران', - destinationCity: 'مشهد', - }); - - expect(upperCase.suggestedPrice).toBe(mixedCase.suggestedPrice); - }); - - it('should apply small city factor for both non-major cities', async () => { - const result = service.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'کرج', - destinationCity: 'قزوین', - }); - - expect(result.breakdown.cityPremiumCost).toBeGreaterThan(0); - }); + expect(service.calculateTransporterEarnings(100000, 5000)).toBe(75000); + expect(service.calculateTransporterEarnings(100000)).toBe(70000); + expect(service.calculateTransporterEarnings(123456.78)).toBe(86419); + expect(service.calculateTransporterEarnings(99999.99, 1234.56)).toBe(71234); }); - describe('Edge cases and validations', () => { - it('should handle missing optional parameters', async () => { - const minimalInput: PricingInput = { - distanceKm: 100, - weightGr: 1, - originCity: 'تهران', - destinationCity: 'اصفهان', - }; - - const result = service.calculateSuggestedPrice(minimalInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.specialHandlingCost).toBe(0); - }); - - it('should handle very large distances', async () => { - const largeDistanceInput = { - ...mockPricingInput, - distanceKm: 5000, - }; - - const result = service.calculateSuggestedPrice(largeDistanceInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.distanceCost).toBeGreaterThan(0); - }); - - it('should handle very heavy packages', async () => { - const heavyPackageInput = { - ...mockPricingInput, - weightGr: 50000, - }; - - const result = service.calculateSuggestedPrice(heavyPackageInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.weightCost).toBeGreaterThan(0); - }); - - it('should handle zero weight packages', async () => { - const zeroWeightInput = { - ...mockPricingInput, - weightGr: 0, - }; - - const result = service.calculateSuggestedPrice(zeroWeightInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.weightCost).toBe(0); - }); + it('calculates deviation cost', () => { + expect(makeService().calculateDeviationCost(5, 10)).toBe(125000); + expect(makeService().calculateDeviationCost(0, 30)).toBe(150000); }); - describe('Configuration variations', () => { - it('should use custom configuration values', async () => { - const customConfig = { - ...defaultConfig, - PRICING_BASE_PRICE: 100000, - PRICING_DRIVER_SHARE: 0.8, - }; - - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return customConfig[key] || defaultValue; - }, - ); - - // Recreate service with new config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const customService = module.get(PricingService); - - const result = customService.calculateSuggestedPrice(mockPricingInput); - const earnings = customService.calculateTransporterEarnings(100000); - - expect(result.breakdown.basePrice).toBe(100000); - expect(earnings).toBe(80000); - }); - - it('should use default values when config is missing', async () => { - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return defaultValue; - }, - ); - - // Recreate service with missing config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const defaultService = module.get(PricingService); - const result = defaultService.calculateSuggestedPrice(mockPricingInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(result.breakdown.basePrice).toBe(50000); // Default value - }); - - it('should parse major cities from configuration', async () => { - const customConfig = { - ...defaultConfig, - PRICING_MAJOR_CITIES: 'شیراز,تبریز,کرج', - }; - - configService.get.mockImplementation( - (key: string, defaultValue?: any) => { - return customConfig[key] || defaultValue; - }, - ); - - // Recreate service with new config - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PricingService, - { provide: ConfigService, useValue: configService }, - ], - }).compile(); - - const customService = module.get(PricingService); - - const shirazToTabriz = customService.calculateSuggestedPrice({ - ...mockPricingInput, - originCity: 'شیراز', - destinationCity: 'تبریز', - }); - - expect(shirazToTabriz.breakdown.cityPremiumCost).toBe(0); - }); + it('calculates distance cost', () => { + expect(makeService().calculateDistanceCost(150)).toBe(177500); + expect(makeService().calculateDistanceCost(0)).toBe(0); }); - describe('Price breakdown validation', () => { - it('should have all breakdown components', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - expect(result.breakdown).toHaveProperty('basePrice'); - expect(result.breakdown).toHaveProperty('distanceCost'); - expect(result.breakdown).toHaveProperty('weightCost'); - expect(result.breakdown).toHaveProperty('specialHandlingCost'); - expect(result.breakdown).toHaveProperty('deviationCost'); - expect(result.breakdown).toHaveProperty('cityPremiumCost'); + it('honours configuration overrides (customBase legacy variant)', () => { + const service = makeService({ + PRICING_BASE_PRICE: 60000, + PRICING_FUEL_RATE: 150, + PRICING_DRIVER_SHARE: 0.65, }); - it('should have positive or zero breakdown values', async () => { - const result = service.calculateSuggestedPrice(mockPricingInput); - - Object.values(result.breakdown).forEach((cost) => { - expect(cost).toBeGreaterThanOrEqual(0); - }); + const price = service.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, + originCity: TEHRAN, + destinationCity: ISFAHAN, }); - it('should calculate total from breakdown components', async () => { - const fragileInput = { ...mockPricingInput, isFragile: true }; - const result = service.calculateSuggestedPrice(fragileInput); - - const calculatedTotal = - result.breakdown.basePrice + - result.breakdown.distanceCost + - result.breakdown.weightCost; - - expect(result.suggestedPrice).toBeGreaterThan(calculatedTotal); - }); - }); - - describe('Mathematical precision', () => { - it('should handle floating point calculations correctly', async () => { - const precisionInput = { - ...mockPricingInput, - distanceKm: 150.5, - weightGr: 2750, - }; - - const result = service.calculateSuggestedPrice(precisionInput); - - expect(result.suggestedPrice).toBeGreaterThan(0); - expect(Number.isInteger(result.suggestedPrice)).toBe(true); - }); - - it('should maintain consistency across multiple calculations', async () => { - const result1 = service.calculateSuggestedPrice(mockPricingInput); - const result2 = service.calculateSuggestedPrice(mockPricingInput); - - expect(result1.suggestedPrice).toBe(result2.suggestedPrice); - expect(result1.breakdown).toEqual(result2.breakdown); - }); + expect(price.suggestedPrice).toBe(175000); + expect(service.calculateTransporterEarnings(175000)).toBe(113750); }); }); diff --git a/src/modules/pricing/pricing.service.ts b/src/modules/pricing/pricing.service.ts index d00924d..89854d2 100644 --- a/src/modules/pricing/pricing.service.ts +++ b/src/modules/pricing/pricing.service.ts @@ -1,231 +1,43 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { PricingInput, PricingResult } from './pricing.types'; - -interface DistanceTier { - minKm: number; - maxKm: number; - ratePerKm: number; -} +import { PricingInput, PricingResult } from './pricing.types'; +import { buildPricingStrategies } from './strategies/pricing-factory'; +import { PricingEngine } from './pricing.engine'; + +/** + * Thin NestJS facade over `PricingEngine`. All arithmetic lives in + * `PricingEngine` and the strategies; this class exists only to wire the + * `PRICING_*` configuration once per boot and expose the three methods the + * rest of the codebase consumes. + */ @Injectable() export class PricingService { - private readonly basePrice: number; - private readonly fuelRate: number; - private readonly weightBaseRate: number; - private readonly platformCommission: number; - private readonly driverShare: number; - private readonly fragileMultiplier: number; - private readonly perishableMultiplier: number; - private readonly bothFragilePerishableMultiplier: number; - private readonly majorCityOrigin: number; - private readonly majorCityDestination: number; - private readonly bothMajorCities: number; - private readonly smallCityFactor: number; - private readonly deviationRate: number; - private readonly timeDeviationRate: number; - private readonly majorCities: string[]; - private readonly distanceTiers: DistanceTier[]; - - constructor(config: ConfigService) { - // Base Constants (in Iranian Rial) - this.basePrice = config.get('PRICING_BASE_PRICE', 50000); - this.fuelRate = config.get('PRICING_FUEL_RATE', 200); - this.weightBaseRate = config.get('PRICING_WEIGHT_BASE_RATE', 10000); - this.driverShare = config.get('PRICING_DRIVER_SHARE', 0.7); - this.platformCommission = 1 - this.driverShare; - - // Multipliers - this.fragileMultiplier = config.get( - 'PRICING_FRAGILE_MULTIPLIER', - 1.25, - ); - this.perishableMultiplier = config.get( - 'PRICING_PERISHABLE_MULTIPLIER', - 1.35, - ); - this.bothFragilePerishableMultiplier = config.get( - 'PRICING_BOTH_FRAGILE_PERISHABLE', - 1.5, - ); - - // City Premium Factors - this.majorCityOrigin = config.get('PRICING_MAJOR_CITY_ORIGIN', 0.9); - this.majorCityDestination = config.get( - 'PRICING_MAJOR_CITY_DESTINATION', - 1.3, - ); - this.bothMajorCities = config.get('PRICING_BOTH_MAJOR_CITIES', 1.0); - this.smallCityFactor = config.get('PRICING_SMALL_CITY_FACTOR', 1.2); - - // Route Deviation Costs - this.deviationRate = config.get('PRICING_DEVIATION_RATE', 15000); - this.timeDeviationRate = config.get( - 'PRICING_TIME_DEVIATION_RATE', - 5000, - ); + private readonly engine: PricingEngine; - // Major Cities List - const majorCitiesString = config.get( - 'PRICING_MAJOR_CITIES', - 'تهران,اصفهان,مشهد', - ); - this.majorCities = majorCitiesString.split(',').map((city) => city.trim()); - - // Distance Pricing Tiers - this.distanceTiers = [ - { - minKm: 0, - maxKm: 100, - ratePerKm: config.get('PRICING_TIER_1_RATE', 1000), - }, - { - minKm: 101, - maxKm: 300, - ratePerKm: config.get('PRICING_TIER_2_RATE', 950), - }, - { - minKm: 301, - maxKm: 600, - ratePerKm: config.get('PRICING_TIER_3_RATE', 850), - }, - { - minKm: 601, - maxKm: 1000, - ratePerKm: config.get('PRICING_TIER_4_RATE', 750), - }, - { - minKm: 1001, - maxKm: Infinity, - ratePerKm: config.get('PRICING_TIER_5_RATE', 600), - }, - ]; + constructor(configService: ConfigService) { + this.engine = new PricingEngine(buildPricingStrategies(configService)); } calculateSuggestedPrice(input: PricingInput): PricingResult { - // Base components - const basePrice = this.basePrice; - const distanceCost = this.calculateDistanceCost(input.distanceKm); - const weightCost = this.calculateWeightCost(input.weightGr); - - // Calculate subtotal before multipliers - let subtotal = basePrice + distanceCost + weightCost; - - // Apply multipliers - const specialMultiplier = this.calculateSpecialHandlingMultiplier( - input.isFragile ?? false, - input.isPerishable ?? false, - ); - subtotal *= specialMultiplier; - - // Apply city premium - const cityPremium = this.calculateCityPremium( - input.originCity, - input.destinationCity, - ); - subtotal *= cityPremium; - - // Round final price - const finalPrice = Math.round(subtotal / 1000) * 1000; - - return { - suggestedPrice: Math.floor(finalPrice), - breakdown: { - basePrice, - distanceCost, - weightCost, - deviationCost: 0, - specialHandlingCost: Math.max(0, specialMultiplier - 1) * finalPrice, - cityPremiumCost: Math.max(0, cityPremium - 1) * finalPrice, - }, - }; - } - - // Calculate distance-based cost using tiered pricing - calculateDistanceCost(distanceKm: number): number { - let totalCost = this.fuelRate * distanceKm; - let remainingDistance = distanceKm; - - for (const tier of this.distanceTiers) { - if (remainingDistance <= 0) break; - - if (tier.maxKm === Infinity) { - totalCost += remainingDistance * tier.ratePerKm; - break; - } - - const tierCapacity = tier.maxKm - tier.minKm + (tier.minKm ? 1 : 0); - const applicableDistance = Math.min(remainingDistance, tierCapacity); - - totalCost += applicableDistance * tier.ratePerKm; - remainingDistance -= applicableDistance; - } - - return totalCost; - } - - calculateTransporterEarnings(finalPrice: number, deviationPrice?: number) { - return Math.floor(finalPrice * this.driverShare + (deviationPrice ?? 0)); + return this.engine.calculateSuggestedPrice(input); } - // Platform commission - - // Calculate weight-based additional cost - private calculateWeightCost(weightGr: number): number { - if (weightGr < 500) { - return 0; // No additional cost for packages ≤ 1kg - } - return (weightGr / 100) * this.weightBaseRate; - } - - // Calculate special multipliers - private calculateSpecialHandlingMultiplier( - isFragile: boolean, - isPerishable: boolean, + calculateTransporterEarnings( + finalPrice: number, + deviationPrice?: number, ): number { - if (isFragile && isPerishable) { - return this.bothFragilePerishableMultiplier; - } else if (isFragile) { - return this.fragileMultiplier; - } else if (isPerishable) { - return this.perishableMultiplier; - } - return 1.0; + return this.engine.calculateTransporterEarnings(finalPrice, deviationPrice); } - // Calculate deviation cost (Based on both distance and duration) calculateDeviationCost( additionalKm: number, additionalMinutes: number, ): number { - const kmCost = additionalKm * this.deviationRate; - const timeCost = additionalMinutes * this.timeDeviationRate; - return kmCost + timeCost; - } - - // Calculate city-based premium factor - private calculateCityPremium( - originCity: string, - destinationCity: string, - ): number { - const originMajor = this.isMajorCity(originCity); - const destMajor = this.isMajorCity(destinationCity); - - if (originMajor && destMajor) { - return this.bothMajorCities; - } else if (originMajor && !destMajor) { - return this.majorCityOrigin; - } else if (!originMajor && destMajor) { - return this.majorCityDestination; - } else { - return this.smallCityFactor; - } + return this.engine.calculateDeviationCost(additionalKm, additionalMinutes); } - // Check if a city is considered a major city - private isMajorCity(cityName: string): boolean { - return this.majorCities.some( - (city) => city.toLowerCase() === cityName.toLowerCase(), - ); + calculateDistanceCost(distanceKm: number): number { + return this.engine.calculateDistanceCost(distanceKm); } } diff --git a/src/modules/pricing/strategies/city-premium.strategy.spec.ts b/src/modules/pricing/strategies/city-premium.strategy.spec.ts new file mode 100644 index 0000000..f3176d2 --- /dev/null +++ b/src/modules/pricing/strategies/city-premium.strategy.spec.ts @@ -0,0 +1,48 @@ +import { CityPremiumStrategy } from './city-premium.strategy'; + +// Persian city names kept as escapes so the spec file stays ASCII on disk. +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; + +describe('CityPremiumStrategy', () => { + const strategy = new CityPremiumStrategy(0.9, 1.3, 1.0, 1.2, [ + TEHRAN, + ISFAHAN, + MASHHAD, + ]); + + it('major origin + major destination uses the both-major factor', () => { + expect(strategy.calculate(TEHRAN, MASHHAD)).toBe(1.0); + }); + + it('major origin only uses the origin factor', () => { + expect(strategy.calculate(TEHRAN, KARAJ)).toBe(0.9); + }); + + it('major destination only uses the destination factor', () => { + expect(strategy.calculate(KARAJ, ISFAHAN)).toBe(1.3); + }); + + it('two small cities use the small-city factor', () => { + expect(strategy.calculate(KARAJ, SHIRAZ)).toBe(1.2); + }); + + it('matches cities case-insensitively (legacy behaviour)', () => { + const latin = new CityPremiumStrategy(0.9, 1.3, 1.0, 1.2, ['Tehran']); + + expect(latin.calculate('tehran', KARAJ)).toBe(0.9); + expect(latin.calculate('TEHRAN', 'TEHRAN')).toBe(1.0); + }); + + it('honours custom factors', () => { + const custom = new CityPremiumStrategy(2, 3, 0.5, 1.1, [TEHRAN, MASHHAD]); + + expect(custom.calculate(MASHHAD, KARAJ)).toBe(2); + expect(custom.calculate(KARAJ, MASHHAD)).toBe(3); + expect(custom.calculate(MASHHAD, MASHHAD)).toBe(0.5); + expect(custom.calculate(KARAJ, SHIRAZ)).toBe(1.1); + }); +}); diff --git a/src/modules/pricing/strategies/city-premium.strategy.ts b/src/modules/pricing/strategies/city-premium.strategy.ts new file mode 100644 index 0000000..fbf9853 --- /dev/null +++ b/src/modules/pricing/strategies/city-premium.strategy.ts @@ -0,0 +1,33 @@ +/** Major-city premium factors, resolved per the legacy four-quadrant rule. */ +export class CityPremiumStrategy { + constructor( + private readonly majorCityOrigin: number, + private readonly majorCityDestination: number, + private readonly bothMajorCities: number, + private readonly smallCityFactor: number, + private readonly majorCities: string[], + ) {} + + calculate(originCity: string, destinationCity: string): number { + const originMajor = this.isMajorCity(originCity); + const destMajor = this.isMajorCity(destinationCity); + + if (originMajor && destMajor) { + return this.bothMajorCities; + } + if (originMajor) { + return this.majorCityOrigin; + } + if (destMajor) { + return this.majorCityDestination; + } + return this.smallCityFactor; + } + + /** Case-insensitive match against the configured `PRICING_MAJOR_CITIES`. */ + private isMajorCity(cityName: string): boolean { + return this.majorCities.some( + (city) => city.toLowerCase() === cityName.toLowerCase(), + ); + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts b/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts new file mode 100644 index 0000000..7b107d8 --- /dev/null +++ b/src/modules/pricing/strategies/deviation-cost.strategy.spec.ts @@ -0,0 +1,27 @@ +import { DeviationCostStrategy } from './deviation-cost.strategy'; + +describe('DeviationCostStrategy', () => { + const strategy = new DeviationCostStrategy(15000, 5000); + + it('charges nothing for a zero deviation', () => { + expect(strategy.calculate(0, 0)).toBe(0); + }); + + it('combines km cost and time cost additively (legacy golden values)', () => { + expect(strategy.calculate(5, 10)).toBe(125000); + expect(strategy.calculate(0, 30)).toBe(150000); + expect(strategy.calculate(100, 0)).toBe(1500000); + }); + + it('handles fractional km and minutes like the legacy engine', () => { + expect(strategy.calculate(12.5, 7.5)).toBe(225000); + }); + + it('honours custom rates', () => { + const custom = new DeviationCostStrategy(100, 10); + + expect(custom.calculate(3, 4)).toBe(340); + expect(custom.calculate(0, 4)).toBe(40); + expect(custom.calculate(3, 0)).toBe(300); + }); +}); diff --git a/src/modules/pricing/strategies/deviation-cost.strategy.ts b/src/modules/pricing/strategies/deviation-cost.strategy.ts new file mode 100644 index 0000000..e3e641b --- /dev/null +++ b/src/modules/pricing/strategies/deviation-cost.strategy.ts @@ -0,0 +1,13 @@ +/** Route-deviation cost: `additionalKm × kmRate + additionalMinutes × timeRate`. */ +export class DeviationCostStrategy { + constructor( + private readonly deviationRate: number, + private readonly timeDeviationRate: number, + ) {} + + calculate(additionalKm: number, additionalMinutes: number): number { + const kmCost = additionalKm * this.deviationRate; + const timeCost = additionalMinutes * this.timeDeviationRate; + return kmCost + timeCost; + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/distance-tier.strategy.spec.ts b/src/modules/pricing/strategies/distance-tier.strategy.spec.ts new file mode 100644 index 0000000..7c95555 --- /dev/null +++ b/src/modules/pricing/strategies/distance-tier.strategy.spec.ts @@ -0,0 +1,72 @@ +import { DistanceTier, DistanceTierStrategy } from './distance-tier.strategy'; + +/** Builds the default 5-tier configuration exactly like pricing-factory does. */ +function buildDefaultTiers( + rates: number[] = [1000, 950, 850, 750, 600], +): DistanceTier[] { + const bounds: Array<[number | null, number]> = [ + [100, rates[0]], + [300, rates[1]], + [600, rates[2]], + [1000, rates[3]], + [null, rates[4]], + ]; + return bounds.map( + ([upToKm, rate], index) => + new DistanceTier( + upToKm, + rate, + index === 0 ? 0 : (bounds[index - 1][0] as number), + ), + ); +} + +describe('DistanceTier', () => { + it('derives spans from cumulative bounds with no hand-written widths', () => { + expect(buildDefaultTiers().map((tier) => tier.getSpanKm())).toEqual([ + 100, 200, 300, 400, Infinity, + ]); + }); +}); + +describe('DistanceTierStrategy (default legacy rates)', () => { + const strategy = new DistanceTierStrategy(200, buildDefaultTiers()); + + it.each([ + [0, 0], + [50.5, 60600], + [100, 120000], + [101, 121150], + [150, 177500], + [265, 309750], + [300, 350000], + [301, 351050], + [600, 665000], + [601, 665950], + [1000, 1045000], + [1001, 1045800], + [1500, 1445000], + [2500, 2245000], + ])('distance %p km costs %p (legacy golden value)', (km, expected) => { + expect(strategy.calculate(km)).toBe(expected); + }); +}); + +describe('DistanceTierStrategy (custom configuration)', () => { + it('honours custom tier rates (legacy customTiers variant)', () => { + const strategy = new DistanceTierStrategy( + 200, + buildDefaultTiers([800, 700, 600, 500, 400]), + ); + + expect(strategy.calculate(101)).toBe(100900); + expect(strategy.calculate(1001)).toBe(800600); + }); + + it('honours a custom fuel rate (legacy customBase variant)', () => { + const strategy = new DistanceTierStrategy(150, buildDefaultTiers()); + + expect(strategy.calculate(100)).toBe(115000); + expect(strategy.calculate(0.5)).toBe(575); + }); +}); diff --git a/src/modules/pricing/strategies/distance-tier.strategy.ts b/src/modules/pricing/strategies/distance-tier.strategy.ts new file mode 100644 index 0000000..0b8eecd --- /dev/null +++ b/src/modules/pricing/strategies/distance-tier.strategy.ts @@ -0,0 +1,58 @@ +/** + * One billed distance bucket. The first tier owns the open interval + * (0, upToKm]; every following tier owns (previousUpToKm, upToKm]. + * + * B-10 fix: the legacy implementation computed tier capacity as + * `maxKm - minKm + (minKm ? 1 : 0)` — a hand-tuned `+1` that silently + * compensated for its own 1-based bounds (101, 301, 601, 1001). Rewriting it + * as cumulative bounds makes the algebra explicit: with `minKm = prevMax + 1`, + * the legacy capacity `maxKm - minKm + 1` reduces exactly to + * `upToKm - previousUpToKm`, so the span below reproduces the legacy output + * byte-for-byte without any magic constant. + */ +export class DistanceTier { + constructor( + /** Cumulative upper bound in km, or `null` for the open-ended last tier. */ + public readonly upToKm: number | null, + public readonly ratePerKm: number, + /** Upper bound of the previous tier (0 for the first tier). */ + private readonly previousUpToKm: number, + ) {} + + /** Kilometres this tier bills, derived purely from the configured bounds. */ + getSpanKm(): number { + if (this.upToKm === null) { + return Infinity; + } + return this.upToKm - this.previousUpToKm; + } +} + +/** Tiered per-kilometre distance cost (plus a flat fuel rate per km). */ +export class DistanceTierStrategy { + constructor( + private readonly fuelRate: number, + private readonly tiers: DistanceTier[], + ) {} + + calculate(distanceKm: number): number { + let totalCost = this.fuelRate * distanceKm; + let remainingDistance = distanceKm; + + for (const tier of this.tiers) { + // Guard clause (B-10): stop as soon as every km has been billed so the + // open-ended tier can never re-bill, and non-positive distances exit + // before any tier arithmetic runs. + if (remainingDistance <= 0) { + break; + } + + const applicableDistance = Math.min(remainingDistance, tier.getSpanKm()); + + totalCost += applicableDistance * tier.ratePerKm; + remainingDistance -= applicableDistance; + } + + return totalCost; + } +} diff --git a/src/modules/pricing/strategies/earnings.strategy.spec.ts b/src/modules/pricing/strategies/earnings.strategy.spec.ts new file mode 100644 index 0000000..ad8401a --- /dev/null +++ b/src/modules/pricing/strategies/earnings.strategy.spec.ts @@ -0,0 +1,28 @@ +import { EarningsStrategy } from './earnings.strategy'; + +describe('EarningsStrategy', () => { + it('matches the legacy earnings table (golden default)', () => { + const strategy = new EarningsStrategy(0.7); + + expect(strategy.calculate(100000)).toBe(70000); + expect(strategy.calculate(100000, 5000)).toBe(75000); + expect(strategy.calculate(0)).toBe(0); + expect(strategy.calculate(123456.78)).toBe(86419); + expect(strategy.calculate(99999.99, 1234.56)).toBe(71234); + }); + + it('floors the final result (legacy Math.floor semantics)', () => { + const strategy = new EarningsStrategy(0.7); + + expect(strategy.calculate(99999.99)).toBe(69999); + }); + + it('honours a custom driver share', () => { + const strategy = new EarningsStrategy(0.65); + + expect(strategy.calculate(100000)).toBe(65000); + expect(strategy.calculate(100000, 5000)).toBe(70000); + expect(strategy.calculate(123456.78)).toBe(80246); + expect(strategy.calculate(99999.99, 1234.56)).toBe(66234); + }); +}); \ No newline at end of file diff --git a/src/modules/pricing/strategies/earnings.strategy.ts b/src/modules/pricing/strategies/earnings.strategy.ts new file mode 100644 index 0000000..be7e79a --- /dev/null +++ b/src/modules/pricing/strategies/earnings.strategy.ts @@ -0,0 +1,11 @@ +/** + * Driver earnings: share of the final price plus any deviation price, + * floored to a whole IRR (legacy `Math.floor` semantics preserved). + */ +export class EarningsStrategy { + constructor(private readonly driverShare: number) {} + + calculate(finalPrice: number, deviationPrice?: number): number { + return Math.floor(finalPrice * this.driverShare + (deviationPrice ?? 0)); + } +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/pricing-factory.spec.ts b/src/modules/pricing/strategies/pricing-factory.spec.ts new file mode 100644 index 0000000..9e7c786 --- /dev/null +++ b/src/modules/pricing/strategies/pricing-factory.spec.ts @@ -0,0 +1,86 @@ +import { ConfigService } from '@nestjs/config'; + +import { PricingEngine } from '../pricing.engine'; +import { buildPricingStrategies, parseMajorCities } from './pricing-factory'; + +const TEHRAN = '\u062A\u0647\u0631\u0627\u0646'; +const ISFAHAN = '\u0627\u0635\u0641\u0647\u0627\u0646'; +const KARAJ = '\u06A9\u0631\u062C'; +const SHIRAZ = '\u0634\u06CC\u0631\u0627\u0632'; +const MASHHAD = '\u0645\u0634\u0647\u062F'; + +function makeEngine(overrides: Record = {}): PricingEngine { + const config = { + get: (key: string, def?: unknown) => + key in overrides ? overrides[key] : def, + }; + return new PricingEngine( + buildPricingStrategies(config as unknown as ConfigService), + ); +} + +describe('parseMajorCities', () => { + it('splits and trims the comma-separated list', () => { + expect(parseMajorCities('\u062A\u0647\u0631\u0627\u0646, \u0627\u0635\u0641\u0647\u0627\u0646 ,\u0645\u0634\u0647\u062F')).toEqual([ + TEHRAN, + ISFAHAN, + MASHHAD, + ]); + }); +}); + +describe('buildPricingStrategies', () => { + it('applies legacy defaults end-to-end', () => { + const engine = makeEngine(); + + expect(engine.calculateTransporterEarnings(100000)).toBe(70000); + expect(engine.calculateDistanceCost(1000)).toBe(1045000); + + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 2, + originCity: TEHRAN, + destinationCity: ISFAHAN, + }); + expect(price.suggestedPrice).toBe(170000); + }); + + it('honours tier-rate overrides', () => { + const engine = makeEngine({ PRICING_TIER_1_RATE: 800 }); + + // fuel 200/km + 800/km tier-1 rate for the first 100 km. + expect(engine.calculateDistanceCost(100)).toBe(100000); + }); + + it('honours base-price and driver-share overrides', () => { + const engine = makeEngine({ + PRICING_BASE_PRICE: 60000, + PRICING_DRIVER_SHARE: 0.65, + }); + + expect(engine.calculateTransporterEarnings(100000)).toBe(65000); + + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: TEHRAN, + destinationCity: ISFAHAN, + }); + expect(price.breakdown.basePrice).toBe(60000); + expect(price.suggestedPrice).toBe(180000); + }); + + it('honours major-city list overrides', () => { + const engine = makeEngine({ PRICING_MAJOR_CITIES: SHIRAZ + ',' + KARAJ }); + + // karaj→shiraz are now both major: factor 1.0 instead of 1.2. + const price = engine.calculateSuggestedPrice({ + distanceKm: 100, + weightGr: 0, + originCity: KARAJ, + destinationCity: SHIRAZ, + }); + expect(price.suggestedPrice).toBe(170000); + expect(price.breakdown.cityPremiumCost).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/modules/pricing/strategies/pricing-factory.ts b/src/modules/pricing/strategies/pricing-factory.ts new file mode 100644 index 0000000..205d344 --- /dev/null +++ b/src/modules/pricing/strategies/pricing-factory.ts @@ -0,0 +1,78 @@ +import { ConfigService } from '@nestjs/config'; + +import { DistanceTier, DistanceTierStrategy } from './distance-tier.strategy'; +import { WeightStrategy } from './weight.strategy'; +import { SpecialHandlingStrategy } from './special-handling.strategy'; +import { CityPremiumStrategy } from './city-premium.strategy'; +import { DeviationCostStrategy } from './deviation-cost.strategy'; +import { EarningsStrategy } from './earnings.strategy'; + +/** + * Single place where `PRICING_*` configuration meets strategy constructors. + * Defaults mirror the legacy `PricingService` constructor exactly, so a + * missing env variable produces identical pricing to the pre-refactor engine. + */ +export function buildPricingStrategies(config: ConfigService) { + const driverShare = config.get('PRICING_DRIVER_SHARE', 0.7); + + const distanceTierStrategy = new DistanceTierStrategy( + config.get('PRICING_FUEL_RATE', 200), + buildDistanceTiers(config), + ); + + return { + distanceTierStrategy, + weightStrategy: new WeightStrategy( + config.get('PRICING_WEIGHT_BASE_RATE', 10000), + ), + specialHandlingStrategy: new SpecialHandlingStrategy( + config.get('PRICING_FRAGILE_MULTIPLIER', 1.25), + config.get('PRICING_PERISHABLE_MULTIPLIER', 1.35), + config.get('PRICING_BOTH_FRAGILE_PERISHABLE', 1.5), + ), + cityPremiumStrategy: new CityPremiumStrategy( + config.get('PRICING_MAJOR_CITY_ORIGIN', 0.9), + config.get('PRICING_MAJOR_CITY_DESTINATION', 1.3), + config.get('PRICING_BOTH_MAJOR_CITIES', 1.0), + config.get('PRICING_SMALL_CITY_FACTOR', 1.2), + parseMajorCities( + config.get('PRICING_MAJOR_CITIES', 'تهران,اصفهان,مشهد'), + ), + ), + deviationCostStrategy: new DeviationCostStrategy( + config.get('PRICING_DEVIATION_RATE', 15000), + config.get('PRICING_TIME_DEVIATION_RATE', 5000), + ), + earningsStrategy: new EarningsStrategy(driverShare), + basePrice: config.get('PRICING_BASE_PRICE', 50000), + }; +} + +export type PricingStrategies = ReturnType; + +/** Parses the comma-separated `PRICING_MAJOR_CITIES` env value. */ +export function parseMajorCities(majorCitiesString: string): string[] { + return majorCitiesString.split(',').map((city) => city.trim()); +} + +/** Cumulative distance tiers; spans are derived, never hand-written. */ +function buildDistanceTiers(config: ConfigService): DistanceTier[] { + const bounds: Array<[number | null, string]> = [ + [100, 'PRICING_TIER_1_RATE'], + [300, 'PRICING_TIER_2_RATE'], + [600, 'PRICING_TIER_3_RATE'], + [1000, 'PRICING_TIER_4_RATE'], + [null, 'PRICING_TIER_5_RATE'], + ]; + + const defaultRates = [1000, 950, 850, 750, 600]; + + return bounds.map(([upToKm, configName], index) => { + const previousUpToKm = index === 0 ? 0 : (bounds[index - 1][0] as number); + return new DistanceTier( + upToKm, + config.get(configName, defaultRates[index]), + previousUpToKm, + ); + }); +} \ No newline at end of file diff --git a/src/modules/pricing/strategies/special-handling.strategy.spec.ts b/src/modules/pricing/strategies/special-handling.strategy.spec.ts new file mode 100644 index 0000000..f5e253f --- /dev/null +++ b/src/modules/pricing/strategies/special-handling.strategy.spec.ts @@ -0,0 +1,29 @@ +import { SpecialHandlingStrategy } from './special-handling.strategy'; + +describe('SpecialHandlingStrategy', () => { + const strategy = new SpecialHandlingStrategy(1.25, 1.35, 1.5); + + it('returns 1 when neither flag is set', () => { + expect(strategy.calculate(false, false)).toBe(1); + }); + + it('returns the fragile multiplier', () => { + expect(strategy.calculate(true, false)).toBe(1.25); + }); + + it('returns the perishable multiplier', () => { + expect(strategy.calculate(false, true)).toBe(1.35); + }); + + it('both flags win over the single-flag multipliers', () => { + expect(strategy.calculate(true, true)).toBe(1.5); + }); + + it('honours custom multipliers', () => { + const custom = new SpecialHandlingStrategy(2, 3, 5); + + expect(custom.calculate(true, false)).toBe(2); + expect(custom.calculate(false, true)).toBe(3); + expect(custom.calculate(true, true)).toBe(5); + }); +}); diff --git a/src/modules/pricing/strategies/special-handling.strategy.ts b/src/modules/pricing/strategies/special-handling.strategy.ts new file mode 100644 index 0000000..d5a50a3 --- /dev/null +++ b/src/modules/pricing/strategies/special-handling.strategy.ts @@ -0,0 +1,21 @@ +/** Fragile/perishable/both multipliers; 1 when neither flag is set. */ +export class SpecialHandlingStrategy { + constructor( + private readonly fragileMultiplier: number, + private readonly perishableMultiplier: number, + private readonly bothFragilePerishableMultiplier: number, + ) {} + + calculate(isFragile: boolean, isPerishable: boolean): number { + if (isFragile && isPerishable) { + return this.bothFragilePerishableMultiplier; + } + if (isFragile) { + return this.fragileMultiplier; + } + if (isPerishable) { + return this.perishableMultiplier; + } + return 1; + } +} diff --git a/src/modules/pricing/strategies/weight.strategy.spec.ts b/src/modules/pricing/strategies/weight.strategy.spec.ts new file mode 100644 index 0000000..89bf24c --- /dev/null +++ b/src/modules/pricing/strategies/weight.strategy.spec.ts @@ -0,0 +1,26 @@ +import { WeightStrategy } from './weight.strategy'; + +describe('WeightStrategy', () => { + const strategy = new WeightStrategy(10000); + + it('charges nothing below the 500 g threshold', () => { + expect(strategy.calculate(0)).toBe(0); + expect(strategy.calculate(1)).toBe(0); + expect(strategy.calculate(499)).toBe(0); + }); + + it('bills from exactly 500 g upward, per 100 g (threshold inclusive)', () => { + expect(strategy.calculate(500)).toBeCloseTo(50000, 6); + expect(strategy.calculate(1000)).toBeCloseTo(100000, 6); + expect(strategy.calculate(2750)).toBeCloseTo(275000, 6); + }); + + it('bills fractional weights with legacy arithmetic', () => { + expect(strategy.calculate(1234)).toBeCloseTo(123400, 6); + expect(strategy.calculate(723.4)).toBeCloseTo(72340, 6); + }); + + it('honours the configured base rate', () => { + expect(new WeightStrategy(12345).calculate(1000)).toBeCloseTo(123450, 6); + }); +}); diff --git a/src/modules/pricing/strategies/weight.strategy.ts b/src/modules/pricing/strategies/weight.strategy.ts new file mode 100644 index 0000000..b521a3e --- /dev/null +++ b/src/modules/pricing/strategies/weight.strategy.ts @@ -0,0 +1,11 @@ +/** Weight surcharge: free below 500 g, then cost per 100 g above. */ +export class WeightStrategy { + constructor(private readonly weightBaseRate: number) {} + + calculate(weightGr: number): number { + if (weightGr < 500) { + return 0; + } + return (weightGr / 100) * this.weightBaseRate; + } +} diff --git a/src/modules/prisma/prisma.module.ts b/src/modules/prisma/prisma.module.ts index ec0ce32..dd47982 100644 --- a/src/modules/prisma/prisma.module.ts +++ b/src/modules/prisma/prisma.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { PrismaService } from './prisma.service'; +import { TransactionRunner } from './transaction-runner'; +import { CityRepository } from './repositories/city.repository'; @Module({ - providers: [PrismaService], - exports: [PrismaService], + providers: [PrismaService, TransactionRunner, CityRepository], + exports: [PrismaService, TransactionRunner, CityRepository], }) export class PrismaModule {} diff --git a/src/modules/prisma/repositories/city.repository.spec.ts b/src/modules/prisma/repositories/city.repository.spec.ts new file mode 100644 index 0000000..1af5cf1 --- /dev/null +++ b/src/modules/prisma/repositories/city.repository.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CityRepository } from './city.repository'; +import { PrismaService } from '../prisma.service'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaClient } from '../../../../generated/prisma'; + +describe('CityRepository', () => { + let repository: CityRepository; + let prisma: DeepMockProxy; + + const mockCity = { id: 'city-123', name: 'Tehran' } as any; + const mockCityWithProvince = { + ...mockCity, + province: { id: 'province-123', name: 'Tehran' }, + }; + + beforeEach(async () => { + prisma = mockDeep(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [CityRepository, { provide: PrismaService, useValue: prisma }], + }).compile(); + + repository = module.get(CityRepository); + }); + + describe('findCityOrThrow', () => { + it('should find a city by id', async () => { + prisma.city.findUniqueOrThrow.mockResolvedValue(mockCity); + + const result = await repository.findCityOrThrow('city-123'); + + expect(result).toEqual(mockCity); + expect(prisma.city.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'city-123' }, + }); + }); + + it('should read through the given transaction when provided', async () => { + const tx = mockDeep(); + tx.city.findUniqueOrThrow.mockResolvedValue(mockCity); + + const result = await repository.findCityOrThrow('city-123', tx); + + expect(result).toEqual(mockCity); + expect(tx.city.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'city-123' }, + }); + expect(prisma.city.findUniqueOrThrow).not.toHaveBeenCalled(); + }); + }); + + describe('findCityWithProvince', () => { + it('should find a city with its province', async () => { + prisma.city.findUniqueOrThrow.mockResolvedValue(mockCityWithProvince); + + const result = await repository.findCityWithProvince('city-123'); + + expect(result).toEqual(mockCityWithProvince); + expect(prisma.city.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'city-123' }, + include: { province: true }, + }); + }); + + it('should read through the given transaction when provided', async () => { + const tx = mockDeep(); + tx.city.findUniqueOrThrow.mockResolvedValue(mockCityWithProvince); + + const result = await repository.findCityWithProvince('city-123', tx); + + expect(result).toEqual(mockCityWithProvince); + expect(tx.city.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'city-123' }, + include: { province: true }, + }); + }); + }); +}); diff --git a/src/modules/prisma/repositories/city.repository.ts b/src/modules/prisma/repositories/city.repository.ts new file mode 100644 index 0000000..edf068e --- /dev/null +++ b/src/modules/prisma/repositories/city.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma.service'; +import { PrismaTransaction } from '../prisma.types'; + +/** + * City reads (Phase 4 Task 6, pulled forward from Phase 5). + * + * Single home for `city` lookups so infra-adjacent reads stop spreading + * across `MapService`, trip creation and recipient creation. Callers inside + * an interactive transaction pass their `tx` for snapshot consistency; + * standalone callers (e.g. the map controller path) use the default client. + * No manual `.catch` — the global filter maps Prisma errors. + */ +@Injectable() +export class CityRepository { + constructor(private prisma: PrismaService) {} + + findCityOrThrow(cityId: string, tx: PrismaTransaction = this.prisma) { + return tx.city.findUniqueOrThrow({ + where: { id: cityId }, + }); + } + + findCityWithProvince(cityId: string, tx: PrismaTransaction = this.prisma) { + return tx.city.findUniqueOrThrow({ + where: { id: cityId }, + include: { province: true }, + }); + } +} diff --git a/src/modules/prisma/transaction-runner.spec.ts b/src/modules/prisma/transaction-runner.spec.ts new file mode 100644 index 0000000..a22c43e --- /dev/null +++ b/src/modules/prisma/transaction-runner.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from './prisma.service'; +import { TransactionRunner } from './transaction-runner'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; + +describe('TransactionRunner', () => { + let runner: TransactionRunner; + let prisma: DeepMockProxy; + let config: DeepMockProxy; + + const createRunner = async (timeoutMs?: number) => { + config.get.mockImplementation((key: string, defaultValue?: unknown) => { + if ( + timeoutMs !== undefined && + key === 'PRISMA_TX_TIMEOUT_MS' + ) { + return timeoutMs; + } + return defaultValue; + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionRunner, + { provide: PrismaService, useValue: prisma }, + { provide: ConfigService, useValue: config }, + ], + }).compile(); + + return module.get(TransactionRunner); + }; + + beforeEach(async () => { + jest.resetAllMocks(); + prisma = mockDeep(); + config = mockDeep(); + // jest-mock-extended returns undefined by default; make $transaction + // actually invoke the callback like Prisma does. + (prisma.$transaction as unknown as jest.Mock).mockImplementation( + async (fn: (tx: unknown) => Promise) => fn({}), + ); + runner = await createRunner(); + }); + + it('delegates to $transaction with the default timeout', async () => { + const fn = jest.fn().mockResolvedValue('ok'); + + const result = await runner.run(fn); + + expect(result).toBe('ok'); + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 5000, + isolationLevel: undefined, + }); + }); + + it('uses the configured timeout from PRISMA_TX_TIMEOUT_MS', async () => { + runner = await createRunner(12345); + const fn = jest.fn().mockResolvedValue('ok'); + + await runner.run(fn); + + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 12345, + isolationLevel: undefined, + }); + }); + + it('honors a per-call timeout override', async () => { + const fn = jest.fn().mockResolvedValue(1); + + await runner.run(fn, { timeoutMs: 250 }); + + expect(prisma.$transaction).toHaveBeenCalledWith(fn, { + timeout: 250, + isolationLevel: undefined, + }); + }); + + it('runIsolated forces serializable isolation unless overridden', async () => { + const fn = jest.fn().mockResolvedValue(1); + + await runner.runIsolated(fn); + expect(prisma.$transaction).toHaveBeenLastCalledWith(fn, { + timeout: 5000, + isolationLevel: 'Serializable', + }); + + await runner.runIsolated(fn, { + isolationLevel: 'ReadCommitted' as never, + }); + expect(prisma.$transaction).toHaveBeenLastCalledWith(fn, { + timeout: 5000, + isolationLevel: 'ReadCommitted', + }); + }); + + it('propagates the transaction result and rethrows non-timeout errors untouched', async () => { + const fn = jest.fn().mockRejectedValue(new Error('boom')); + + await expect(runner.run(fn)).rejects.toThrow('boom'); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('maps P2028 to TransactionTimedOutError with the effective timeout', async () => { + const p2028 = Object.assign(new Error('Timed out fetching a new page'), { + code: 'P2028', + }); + const fn = jest.fn().mockRejectedValue(p2028); + + await expect(runner.run(fn)).rejects.toMatchObject({ + name: 'TransactionTimedOutError', + message: 'Transaction exceeded the configured timeout of 5000ms', + }); + }); + + it('maps P2028 to TransactionTimedOutError with the override timeout in the message', async () => { + const p2028 = Object.assign(new Error('Timed out'), { code: 'P2028' }); + const fn = jest.fn().mockRejectedValue(p2028); + + await expect(runner.run(fn, { timeoutMs: 250 })).rejects.toMatchObject({ + message: 'Transaction exceeded the configured timeout of 250ms', + }); + }); +}); diff --git a/src/modules/prisma/transaction-runner.ts b/src/modules/prisma/transaction-runner.ts new file mode 100644 index 0000000..6cd16e2 --- /dev/null +++ b/src/modules/prisma/transaction-runner.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma } from '../../../generated/prisma'; +import { ConfigKey } from '../../common/config/config-names'; +import { PrismaService } from './prisma.service'; +import { PrismaTransaction } from './prisma.types'; + +/** Default interactive-transaction timeout when none is configured (ms). */ +const DEFAULT_TX_TIMEOUT_MS = 5000; + +/** + * Thrown when a transaction exceeds its configured timeout (Prisma P2028). + * All other transaction errors are rethrown untouched — mapping Prisma errors + * to HTTP responses stays the responsibility of the global exceptions filter. + */ +export class TransactionTimedOutError extends Error { + constructor(timeoutMs: number) { + super(`Transaction exceeded the configured timeout of ${timeoutMs}ms`); + this.name = 'TransactionTimedOutError'; + } +} + +export interface TransactionRunnerOptions { + /** Override the configured default timeout (ms) for this transaction. */ + timeoutMs?: number; + /** Explicit isolation level — `run()` leaves it at the engine default. */ + isolationLevel?: Prisma.TransactionIsolationLevel; +} + +type TransactionFn = (tx: PrismaTransaction) => Promise; + +function isTransactionTimeoutError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'P2028' + ); +} + +/** + * Single entry point for interactive Prisma transactions. + * + * Services should run multi-statement writes through this runner instead of + * calling `prisma.$transaction` directly so that: + * - the timeout is centralized and configurable (`PRISMA_TX_TIMEOUT_MS`); + * - timeouts surface as a typed `TransactionTimedOutError` (P2028); + * - code-minting flows can opt into `runIsolated` (serializable) so unique + * constraint retries under concurrency are safe. + */ +@Injectable() +export class TransactionRunner { + private readonly defaultTimeoutMs: number; + + constructor( + config: ConfigService, + private readonly prisma: PrismaService, + ) { + this.defaultTimeoutMs = config.get( + ConfigKey.Database.TransactionTimeoutMs, + DEFAULT_TX_TIMEOUT_MS, + ); + } + + /** Run `fn` inside one interactive transaction (engine-default isolation). */ + run( + fn: TransactionFn, + options: TransactionRunnerOptions = {}, + ): Promise { + return this.execute(fn, options); + } + + /** + * Run `fn` inside a serializable transaction. Use for flows that mint + * unique codes or read-then-write shared rows, where concurrent + * transactions must not interleave. + */ + runIsolated( + fn: TransactionFn, + options: TransactionRunnerOptions = {}, + ): Promise { + return this.execute(fn, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + ...options, + }); + } + + private execute( + fn: TransactionFn, + { timeoutMs, isolationLevel }: TransactionRunnerOptions, + ): Promise { + const timeout = timeoutMs ?? this.defaultTimeoutMs; + return this.prisma + .$transaction(fn, { timeout, isolationLevel }) + .catch((error: unknown) => { + if (isTransactionTimeoutError(error)) { + throw new TransactionTimedOutError(timeout); + } + throw error; + }); + } +} diff --git a/src/modules/trip/application/trip.service.spec.ts b/src/modules/trip/application/trip.service.spec.ts new file mode 100644 index 0000000..23380a7 --- /dev/null +++ b/src/modules/trip/application/trip.service.spec.ts @@ -0,0 +1,819 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripService } from './trip.service'; +import { TripTrackingService } from '../domain/trip-tracking.service'; +import { FinancialService } from '../../financial/financial.service'; +import { MapService } from '../../map/map.service'; +import { + PrismaClient, + TripStatusEnum, + TripTypeEnum, + PackageStatusEnum, +} from '../../../../generated/prisma'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + BadRequestMessages, + AuthMessages, + TrackingMessages, +} from '../../../common/enums/messages.enum'; +import { NotificationService } from '../../notification/notification.service'; +import { CityRepository } from '../../prisma/repositories/city.repository'; + +describe('TripService', () => { + let service: TripService; + let prisma: DeepMockProxy; + let mapService: DeepMockProxy; + let financialService: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + let trackingService: DeepMockProxy; + let cities: DeepMockProxy; + + const mockVehicle = { + id: 'vehicle-123', + ownerId: 'transporter-123', + owner: { + userId: 'user-123', + }, + } as any; + + const mockCity = { + id: 'city-123', + name: 'Tehran', + latitude: '35.6892', + longitude: '51.3890', + } as any; + + const mockTrip = { + id: 'trip-123', + transporterId: 'transporter-123', + originId: 'city-origin', + destinationId: 'city-dest', + vehicleId: 'vehicle-123', + status: TripStatusEnum.scheduled, + origin: mockCity, + destination: mockCity, + waypoints: [], + vehicle: mockVehicle, + normalDistanceKm: 100, + normalDurationMin: 120, + totalDeviationDistanceKm: 0, + totalDeviationDurationMin: 0, + matchedRequests: [], + } as any; + + const mockCreateTripDto = { + originId: 'city-origin', + destinationId: 'city-dest', + vehicleId: 'vehicle-123', + departureTime: [new Date(), new Date(Date.now() + 3600000)] as [Date, Date], + maxPackageWeightGr: 5000, + restrictedItems: ['fragile'], + description: 'Test trip', + }; + + const mockMatchedRequest = { + id: 'matched-123', + tripId: 'trip-123', + packageId: 'package-123', + trackingCode: '17571445988911932924', + deliveryCode: '12345', + transporterNotes: [], + pickupTime: null, + deliveryTime: null, + package: { + id: 'package-123', + status: PackageStatusEnum.matched, + senderId: 'user-123', + originAddress: { + latitude: '35.6892', + longitude: '51.3890', + city: 'Tehran', + }, + recipient: { + address: { + latitude: '35.7219', + longitude: '51.3347', + city: 'Tehran', + }, + }, + breakdown: { baseCost: 100000, deviationCost: 0 }, + }, + trip: { + status: TripStatusEnum.in_progress, + }, + } as any; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + mapService = mockDeep(); + financialService = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + trackingService = mockDeep(); + cities = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TripService, + { provide: PrismaService, useValue: prisma }, + { provide: MapService, useValue: mapService }, + { provide: FinancialService, useValue: financialService }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + { provide: TripTrackingService, useValue: trackingService }, + { provide: CityRepository, useValue: cities }, + ], + }).compile(); + + service = module.get(TripService); + }); + + describe('create', () => { + it('should create trip successfully', async () => { + prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); + cities.findCityOrThrow.mockResolvedValue(mockCity); + mapService.calculateDistance.mockResolvedValue({ + distance: 100, + duration: 120, + }); + prisma.trip.create.mockResolvedValue(mockTrip); + + const result = await service.create('user-123', mockCreateTripDto); + + expect(result).toEqual(mockTrip); + expect(prisma.vehicle.findFirst).toHaveBeenCalledWith({ + where: { id: 'vehicle-123', owner: { userId: 'user-123' } }, + }); + expect(cities.findCityOrThrow).toHaveBeenCalledWith( + 'city-origin', + prisma, + ); + expect(cities.findCityOrThrow).toHaveBeenCalledWith('city-dest', prisma); + expect(mapService.calculateDistance).toHaveBeenCalled(); + expect(prisma.trip.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + transporterId: 'transporter-123', + originId: 'city-origin', + destinationId: 'city-dest', + vehicleId: 'vehicle-123', + tripType: TripTypeEnum.intercity, + normalDistanceKm: 100, + normalDurationMin: 120, + }), + }); + }); + + it('should create trip with waypoints', async () => { + const tripDtoWithWaypoints = { + ...mockCreateTripDto, + waypoints: [{ id: 'way-1', name: 'Waypoint 1' }], + }; + + prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); + cities.findCityOrThrow.mockResolvedValue(mockCity); + mapService.calculateDistance.mockResolvedValue({ + distance: 100, + duration: 120, + }); + prisma.trip.create.mockResolvedValue(mockTrip); + + await service.create('user-123', tripDtoWithWaypoints as any); + + expect(prisma.trip.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + waypoints: { + createMany: { + data: [{ id: 'way-1', name: 'Waypoint 1' }], + }, + }, + }), + }); + }); + + it('should throw when vehicle not found', async () => { + prisma.vehicle.findFirst.mockResolvedValue(null); + + await expect( + service.create('user-123', mockCreateTripDto), + ).rejects.toThrow( + new ForbiddenException(`${AuthMessages.EntityAccessDenied} vehicle.`), + ); + }); + + it('should run creation inside TransactionRunner', async () => { + prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); + cities.findCityOrThrow.mockResolvedValue(mockCity); + mapService.calculateDistance.mockResolvedValue({ + distance: 100, + duration: 120, + }); + prisma.trip.create.mockResolvedValue(mockTrip); + + await service.create('user-123', mockCreateTripDto); + + expect(runner.run).toHaveBeenCalledTimes(1); + }); + }); + + describe('getById', () => { + it('should get trip by id successfully', async () => { + const tripWithIncludes = { + ...mockTrip, + waypoints: [{ id: 'way-1', isVisible: true }], + }; + prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithIncludes); + + const result = await service.getById('trip-123'); + + expect(result).toEqual(tripWithIncludes); + expect(prisma.trip.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + include: { + origin: true, + destination: true, + waypoints: { where: { isVisible: true } }, + vehicle: { + select: { + vehicleType: true, + model: { include: { brand: true } }, + manufactureYear: true, + color: true, + }, + }, + }, + }); + }); + }); + + describe('getMultipleById', () => { + it('should get multiple trips by ids', async () => { + const trips = [mockTrip]; + prisma.trip.findMany.mockResolvedValue(trips); + + const result = await service.getMultipleById( + ['trip-123', 'trip-456'], + prisma, + ); + + expect(result).toEqual(trips); + expect(prisma.trip.findMany).toHaveBeenCalledWith({ + where: { + id: { in: ['trip-123', 'trip-456'] }, + status: TripStatusEnum.scheduled, + }, + include: { + origin: true, + destination: true, + waypoints: true, + vehicle: { + include: { + model: { include: { brand: true } }, + }, + }, + matchedRequests: { + include: { + package: { + include: { + originAddress: true, + recipient: { include: { address: true } }, + }, + }, + }, + }, + }, + }); + }); + + it('should default to the injected client when no tx is provided', async () => { + prisma.trip.findMany.mockResolvedValue([mockTrip]); + + const result = await service.getMultipleById(['trip-123']); + + expect(result).toEqual([mockTrip]); + expect(prisma.trip.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: TripStatusEnum.scheduled, + }), + }), + ); + }); + }); + + describe('getAll', () => { + it('should get all trips with default status filter', async () => { + const trips = [mockTrip]; + prisma.trip.findMany.mockResolvedValue(trips); + + const result = await service.getAll('user-123'); + + expect(result).toEqual(trips); + expect(prisma.trip.findMany).toHaveBeenCalledWith({ + where: { + transporter: { userId: 'user-123' }, + status: { + in: [ + TripStatusEnum.scheduled, + TripStatusEnum.closed, + TripStatusEnum.delayed, + TripStatusEnum.in_progress, + ], + }, + deletedAt: null, + }, + include: { + origin: true, + destination: true, + waypoints: { where: { isVisible: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('should get all trips with custom status filter', async () => { + const trips = [mockTrip]; + prisma.trip.findMany.mockResolvedValue(trips); + + await service.getAll('user-123', [TripStatusEnum.scheduled]); + + expect(prisma.trip.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { in: [TripStatusEnum.scheduled] }, + }), + }), + ); + }); + }); + + describe('update', () => { + const updateDto = { + maxPackageWeightGr: 6000, + description: 'Updated description', + }; + + it('should update trip successfully', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.scheduled, + } as any); + prisma.trip.update.mockResolvedValue({ ...mockTrip, ...updateDto }); + + const result = await service.update('trip-123', updateDto); + + expect(result).toEqual({ ...mockTrip, ...updateDto }); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: updateDto, + }); + }); + + it('should update trip with waypoints', async () => { + const updateDtoWithWaypoints = { + ...updateDto, + waypoints: [{ id: 'way-2', name: 'New waypoint' }], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.scheduled, + } as any); + prisma.tripWaypoint.deleteMany.mockResolvedValue({ count: 1 }); + prisma.trip.update.mockResolvedValue(mockTrip); + + await service.update('trip-123', updateDtoWithWaypoints as any); + + expect(prisma.tripWaypoint.deleteMany).toHaveBeenCalledWith({ + where: { tripId: 'trip-123', isVisible: true }, + }); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: expect.objectContaining({ + waypoints: { + createMany: { + data: [{ id: 'way-2', name: 'New waypoint' }], + }, + }, + }), + }); + }); + + it('should throw when trip status is not scheduled', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + } as any); + + await expect(service.update('trip-123', updateDto)).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.in_progress}*.`, + ), + ); + }); + }); + + describe('delete', () => { + it('should delete trip successfully', async () => { + const deletedTrip = { ...mockTrip, deletedAt: new Date() }; + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.scheduled, + } as any); + prisma.trip.update.mockResolvedValue(deletedTrip); + + const result = await service.delete('trip-123'); + + expect(result).toEqual(deletedTrip); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { deletedAt: expect.any(Date) }, + }); + }); + + it('should throw when trip status is not scheduled', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.completed, + } as any); + + await expect(service.delete('trip-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, + ), + ); + }); + }); + + describe('toggleTripAccess', () => { + it('should toggle from scheduled to closed', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.scheduled, + } as any); + prisma.trip.update.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.closed, + }); + + const result = await service.toggleTripAccess('trip-123'); + + expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.closed }); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { status: TripStatusEnum.closed }, + }); + }); + + it('should toggle from closed to scheduled', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + status: TripStatusEnum.closed, + } as any); + prisma.trip.update.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.scheduled, + }); + + const result = await service.toggleTripAccess('trip-123'); + + expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.scheduled }); + }); + + it('should throw when trip status is invalid', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.completed, + } as any); + + await expect(service.toggleTripAccess('trip-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, + ), + ); + }); + }); + + describe('startTrip', () => { + it('should start trip successfully', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.scheduled, + origin: mockCity, + } as any); + trackingService.updateTracking.mockResolvedValue({ count: 2 }); + prisma.trip.update.mockResolvedValueOnce({ + ...mockTrip, + status: TripStatusEnum.in_progress, + }); + + const result = await service.startTrip('trip-123'); + + expect(result).toEqual({ + ...mockTrip, + status: TripStatusEnum.in_progress, + }); + expect(trackingService.updateTracking).toHaveBeenCalledWith( + 'trip-123', + { + city: 'Tehran', + description: TrackingMessages.TripStarted, + }, + prisma, + ); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { status: TripStatusEnum.in_progress }, + }); + }); + + it('should throw when trip status cannot start', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.completed, + origin: mockCity, + } as any); + + await expect(service.startTrip('trip-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, + ), + ); + }); + }); + + describe('pickupPackage', () => { + it('should pickup package successfully', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( + mockMatchedRequest, + ); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.in_transit, + } as any); + prisma.matchedRequest.update.mockResolvedValue({ + pickupTime: new Date(), + } as any); + prisma.trackingUpdate.create.mockResolvedValue({} as any); + + const result = await service.pickupPackage('trip-123', 'package-123'); + + expect(result.packageStatus).toBe(PackageStatusEnum.in_transit); + expect(result.pickupTime).toBeInstanceOf(Date); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123', deletedAt: null }, + data: { status: PackageStatusEnum.in_transit }, + }); + expect(prisma.trackingUpdate.create).toHaveBeenCalledWith({ + data: { + matchedRequestId: 'matched-123', + latitude: '35.6892', + longitude: '51.3890', + city: 'Tehran', + description: TrackingMessages.PackagePickedUp, + }, + }); + }); + + it('should throw when package status is invalid', async () => { + const invalidMatchedRequest = { + ...mockMatchedRequest, + package: { + ...mockMatchedRequest.package, + status: PackageStatusEnum.delivered, + }, + }; + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( + invalidMatchedRequest, + ); + + await expect( + service.pickupPackage('trip-123', 'package-123'), + ).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.delivered}*.`, + ), + ); + }); + + it('should throw when trip status is invalid', async () => { + const invalidMatchedRequest = { + ...mockMatchedRequest, + trip: { status: TripStatusEnum.completed }, + }; + + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( + invalidMatchedRequest, + ); + + await expect( + service.pickupPackage('trip-123', 'package-123'), + ).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${PackageStatusEnum.matched}*.`, + ), + ); + }); + }); + + describe('deliveryPackage', () => { + const inTransitMatchedRequest = { + ...mockMatchedRequest, + package: { + ...mockMatchedRequest.package, + status: PackageStatusEnum.in_transit, + }, + }; + + it('should deliver package successfully', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + inTransitMatchedRequest, + ); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.delivered, + } as any); + prisma.matchedRequest.update.mockResolvedValue({ + deliveryTime: new Date(), + } as any); + prisma.trackingUpdate.create.mockResolvedValue({} as any); + financialService.releaseEscrow.mockResolvedValue({} as any); + + const result = await service.deliveryPackage( + 'trip-123', + 'package-123', + '12345', + ); + + expect(result.packageStatus).toBe(PackageStatusEnum.delivered); + expect(result.deliveryTime).toBeInstanceOf(Date); + expect(financialService.releaseEscrow).toHaveBeenCalledWith( + 'package-123', + 'trip-123', + prisma, + ); + }); + + it('should throw when delivery code is wrong', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + inTransitMatchedRequest, + ); + + await expect( + service.deliveryPackage('trip-123', 'package-123', '54321'), + ).rejects.toThrow( + new BadRequestException(BadRequestMessages.WrongDeliveryCode), + ); + }); + + it('should throw when package is not in transit', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockMatchedRequest, + ); + + await expect( + service.deliveryPackage('trip-123', 'package-123', '12345'), + ).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.matched}*.`, + ), + ); + }); + }); + + describe('finishTrip', () => { + it('should finish trip successfully', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + matchedRequests: [{ deliveryTime: new Date() }], + transporter: { + id: 'transporter-123', + firstTripDate: null, + }, + } as any); + prisma.trip.update.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.completed, + }); + prisma.transporter.update.mockResolvedValue({} as any); + + const result = await service.finishTrip('trip-123'); + + expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.completed }); + expect(prisma.transporter.update).toHaveBeenCalledWith({ + where: { id: 'transporter-123' }, + data: { firstTripDate: expect.any(Date) }, + }); + }); + + it('should finish trip successfully when transporter has firstTripDate', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + matchedRequests: [{ deliveryTime: new Date() }], + transporter: { + id: 'transporter-123', + firstTripDate: new Date('2023-01-01'), + }, + } as any); + prisma.trip.update.mockResolvedValue({ + ...mockTrip, + status: TripStatusEnum.completed, + }); + prisma.transporter.update.mockResolvedValue({} as any); + + const result = await service.finishTrip('trip-123'); + + expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.completed }); + expect(prisma.transporter.update).toHaveBeenCalledWith({ + where: { id: 'transporter-123' }, + data: { lastTripDate: expect.any(Date) }, + }); + }); + + it('should throw when not all packages are delivered', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + matchedRequests: [{ deliveryTime: null }], + transporter: { + id: 'transporter-123', + firstTripDate: null, + }, + } as any); + + await expect(service.finishTrip('trip-123')).rejects.toThrow( + new BadRequestException(BadRequestMessages.CannotFinishTrip), + ); + }); + + it('should throw when trip is not in progress', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.scheduled, + matchedRequests: [], + transporter: { id: 'transporter-123', firstTripDate: null }, + } as any); + + await expect(service.finishTrip('trip-123')).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.scheduled}*.`, + ), + ); + }); + }); + + describe('Edge cases', () => { + it('should handle empty waypoints array in create', async () => { + const tripDtoWithEmptyWaypoints = { + ...mockCreateTripDto, + waypoints: undefined, + }; + + prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); + cities.findCityOrThrow.mockResolvedValue(mockCity); + mapService.calculateDistance.mockResolvedValue({ + distance: 100, + duration: 120, + }); + prisma.trip.create.mockResolvedValue(mockTrip); + + await service.create('user-123', tripDtoWithEmptyWaypoints); + + expect(prisma.trip.create).toHaveBeenCalledWith({ + data: expect.not.objectContaining({ + waypoints: expect.anything(), + }), + }); + }); + }); + + describe('Private method tests', () => { + it('should update package status correctly', async () => { + const updatedPackage = { + id: 'package-123', + status: PackageStatusEnum.in_transit, + }; + prisma.package.update.mockResolvedValue(updatedPackage as any); + + const result = await service['updatePackageStatus']( + 'package-123', + PackageStatusEnum.in_transit, + prisma, + ); + + expect(result).toEqual(updatedPackage); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123', deletedAt: null }, + data: { status: PackageStatusEnum.in_transit }, + }); + }); + + it('should update trip status correctly', async () => { + const updatedTrip = { ...mockTrip, status: TripStatusEnum.completed }; + prisma.trip.update.mockResolvedValue(updatedTrip); + + const result = await service['updateStatus']( + 'trip-123', + TripStatusEnum.completed, + ); + + expect(result).toEqual(updatedTrip); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { status: TripStatusEnum.completed }, + }); + }); + }); +}); diff --git a/src/modules/trip/application/trip.service.ts b/src/modules/trip/application/trip.service.ts new file mode 100644 index 0000000..0846495 --- /dev/null +++ b/src/modules/trip/application/trip.service.ts @@ -0,0 +1,648 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { MapService } from '../../map/map.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { CreateTripDto } from '../dto/create-trip.dto'; +import { + AuthMessages, + BadRequestMessages, + TrackingMessages, +} from '../../../common/enums/messages.enum'; +import { + PackageStatusEnum, + Prisma, + TripStatusEnum, + TripTypeEnum, +} from '../../../../generated/prisma'; +import { UpdateTripDto } from '../dto/update-trip.dto'; +import { PrismaTransaction } from '../../prisma/prisma.types'; +import { FinancialService } from '../../financial/financial.service'; +import { NotificationService } from '../../notification/notification.service'; +import { CityRepository } from '../../prisma/repositories/city.repository'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; +import { TripTrackingService } from '../domain/trip-tracking.service'; + +/** + * Trip lifecycle + CRUD orchestration (Phase 4 Task 4). + * + * Slimmed from the 35 KB god service: request handling lives in + * `TripRequestService`, notes/tracking/rating/directions in + * `TripTrackingService`. All multi-statement writes run through + * `TransactionRunner` (no manual `.catch` — the global filter maps Prisma + * errors). Fixes vs the original: `update`/`delete` now write through `tx` + * (they used the root client inside the transaction), and `startTrip` / + * `pickupPackage` / `deliveryPackage` now pass `tx` into the tracking and + * package-status helpers (they silently ran outside the transaction). + */ +@Injectable() +export class TripService { + constructor( + private prisma: PrismaService, + private mapService: MapService, + private financialService: FinancialService, + private notificationService: NotificationService, + private runner: TransactionRunner, + private trackingService: TripTrackingService, + private cities: CityRepository, + ) {} + + async create( + userId: string, + { + vehicleId, + waypoints, + originId, + destinationId, + ...tripDto + }: CreateTripDto, + tripType: TripTypeEnum = TripTypeEnum.intercity, + ) { + return this.runner.run(async (tx) => { + const vehicle = await tx.vehicle.findFirst({ + where: { + id: vehicleId, + owner: { + userId, + }, + }, + }); + + if (!vehicle) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} vehicle.`, + ); + } + + const originCity = await this.cities.findCityOrThrow(originId, tx); + + const destinationCity = await this.cities.findCityOrThrow( + destinationId, + tx, + ); + + const { distance, duration } = await this.mapService.calculateDistance({ + origin: { + latitude: originCity.latitude, + longitude: originCity.longitude, + }, + destination: { + latitude: destinationCity.latitude, + longitude: destinationCity.longitude, + }, + waypoints, + }); + + const tripData = { + transporterId: vehicle.ownerId, + originId, + destinationId, + vehicleId: vehicle.id, + tripType, + normalDistanceKm: distance, + normalDurationMin: duration, + ...tripDto, + } as Prisma.TripUncheckedCreateInput; + + if (waypoints) { + // TODO: Sort waypoints? + tripData.waypoints = { + createMany: { + data: waypoints, + }, + }; + } + + const trip = await tx.trip.create({ + data: tripData, + }); + + // Add create trip notification + await this.notificationService.create( + userId, + { + tripId: trip.id, + content: getNotificationMessage(NotificationMessages.TripCreated, { + tripCode: trip.code, + }), + }, + tx, + ); + + return trip; + }); + } + + async getById(id: string) { + return this.prisma.trip.findUniqueOrThrow({ + where: { id }, + include: { + origin: true, + destination: true, + waypoints: { + where: { + isVisible: true, + }, + }, + vehicle: { + select: { + vehicleType: true, + model: { + include: { + brand: true, + }, + }, + manufactureYear: true, + color: true, + }, + }, + }, + }); + } + + async getMultipleById(ids: string[], tx: PrismaTransaction = this.prisma) { + return tx.trip.findMany({ + where: { + id: { + in: ids, + }, + status: TripStatusEnum.scheduled, + }, + include: { + origin: true, + destination: true, + waypoints: true, + vehicle: { + include: { + model: { + include: { + brand: true, + }, + }, + }, + }, + matchedRequests: { + include: { + package: { + include: { + originAddress: true, + recipient: { + include: { + address: true, + }, + }, + }, + }, + }, + }, + }, + }); + } + + async getAll( + userId: string, + status: TripStatusEnum[] = [ + TripStatusEnum.scheduled, + TripStatusEnum.closed, + TripStatusEnum.delayed, + TripStatusEnum.in_progress, + ], + ) { + return this.prisma.trip.findMany({ + where: { + transporter: { + userId, + }, + status: { + in: status, + }, + deletedAt: null, + }, + include: { + origin: true, + destination: true, + waypoints: { + where: { + isVisible: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async update(id: string, { waypoints, ...tripDto }: UpdateTripDto) { + return this.runner.run(async (tx) => { + const { status } = await tx.trip.findUniqueOrThrow({ + where: { + id, + deletedAt: null, + }, + select: { + status: true, + }, + }); + + if (status !== TripStatusEnum.scheduled) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${status}*.`, + ); + } + + const tripData = tripDto as Prisma.TripUpdateInput; + + if (waypoints) { + tripData.waypoints = { + createMany: { + data: waypoints, + }, + }; + + await tx.tripWaypoint.deleteMany({ + where: { + tripId: id, + isVisible: true, + }, + }); + } + + return tx.trip.update({ + where: { id }, + data: tripData, + }); + }); + } + + async delete(id: string) { + return this.runner.run(async (tx) => { + const { status } = await tx.trip.findUniqueOrThrow({ + where: { + id, + deletedAt: null, + }, + select: { + status: true, + }, + }); + + if (status !== TripStatusEnum.scheduled) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${status}*.`, + ); + } + + return tx.trip.update({ + where: { id }, + data: { + deletedAt: new Date(), + }, + }); + }); + } + + async toggleTripAccess(id: string) { + const { status: tripStatus } = await this.prisma.trip.findUniqueOrThrow({ + where: { id }, + select: { + status: true, + }, + }); + + const isValidStatus = + tripStatus === TripStatusEnum.scheduled || + tripStatus === TripStatusEnum.closed; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, + ); + } + + const updatedStatus = + tripStatus === TripStatusEnum.scheduled + ? TripStatusEnum.closed + : TripStatusEnum.scheduled; + + return this.updateStatus(id, updatedStatus); + } + + async startTrip(id: string) { + const { status: tripStatus, origin } = + await this.prisma.trip.findUniqueOrThrow({ + where: { id }, + select: { + status: true, + origin: true, + }, + }); + + const isValidStatus = + tripStatus === TripStatusEnum.scheduled || + tripStatus === TripStatusEnum.closed || + tripStatus === TripStatusEnum.delayed; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, + ); + } + + return this.runner.run(async (tx) => { + await this.trackingService.updateTracking( + id, + { + city: origin.name, + description: TrackingMessages.TripStarted, + }, + tx, + ); + + return this.updateStatus(id, TripStatusEnum.in_progress, tx); + }); + } + + async pickupPackage(tripId: string, packageId: string) { + const { + id: matchedRequestId, + package: packageData, + trip, + } = await this.prisma.matchedRequest.findUniqueOrThrow({ + where: { + tripId, + packageId, + }, + select: { + id: true, + package: { + select: { + code: true, + senderId: true, + status: true, + originAddress: true, + }, + }, + trip: { + select: { + status: true, + }, + }, + }, + }); + + if (packageData.status !== PackageStatusEnum.matched) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${packageData.status}*.`, + ); + } + + if (trip.status !== TripStatusEnum.in_progress) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${packageData.status}*.`, + ); + } + + return this.runner.run(async (tx) => { + // Update package status + const { status: packageStatus } = await this.updatePackageStatus( + packageId, + PackageStatusEnum.in_transit, + tx, + ); + + // Set pickupTime + const { pickupTime } = await tx.matchedRequest.update({ + where: { + tripId, + packageId, + }, + data: { + pickupTime: new Date(), + }, + }); + + // Update tracking + await tx.trackingUpdate.create({ + data: { + matchedRequestId, + latitude: packageData.originAddress.latitude, + longitude: packageData.originAddress.longitude, + city: packageData.originAddress.city, + description: TrackingMessages.PackagePickedUp, + }, + }); + + // Add pickup package notification + await this.notificationService.create( + packageData.senderId, + { + packageId, + tripId, + content: getNotificationMessage( + NotificationMessages.PackagePickedUp, + { packageCode: packageData.code }, + ), + }, + tx, + ); + + return { + packageStatus, + pickupTime, + }; + }); + } + + async deliveryPackage(tripId: string, packageId: string, code: string) { + const { + id: matchedRequestId, + package: packageData, + trip, + deliveryCode, + } = await this.prisma.matchedRequest.findUniqueOrThrow({ + where: { + tripId, + packageId, + }, + select: { + id: true, + package: { + select: { + code: true, + senderId: true, + status: true, + recipient: { + include: { + address: true, + }, + }, + }, + }, + trip: { + select: { + status: true, + }, + }, + deliveryCode: true, + }, + }); + + if (packageData.status !== PackageStatusEnum.in_transit) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${packageData.status}*.`, + ); + } + + if (trip.status !== TripStatusEnum.in_progress) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${trip.status}*.`, + ); + } + + if (code !== deliveryCode) { + throw new BadRequestException(BadRequestMessages.WrongDeliveryCode); + } + + return this.runner.run(async (tx) => { + // Update package status + const { status: packageStatus } = await this.updatePackageStatus( + packageId, + PackageStatusEnum.delivered, + tx, + ); + + // Set deliveryTime + const { deliveryTime } = await tx.matchedRequest.update({ + where: { + tripId, + packageId, + }, + data: { + deliveryTime: new Date(), + }, + }); + + // Update tracking + await tx.trackingUpdate.create({ + data: { + matchedRequestId, + latitude: packageData.recipient.address.latitude, + longitude: packageData.recipient.address.longitude, + city: packageData.recipient.address.city, + description: TrackingMessages.PackageDelivered, + }, + }); + + // Release escrow (fatal: aborts the transaction on failure) + await this.financialService.releaseEscrow(packageId, tripId, tx); + + // TODO: Send SMS + + // Add delivery package notification + await this.notificationService.create( + packageData.senderId, + { + packageId, + tripId, + content: getNotificationMessage( + NotificationMessages.PackageDelivered, + { packageCode: packageData.code }, + ), + }, + tx, + ); + + return { + packageStatus, + deliveryTime, + }; + }); + } + + async finishTrip(id: string) { + const { + status: tripStatus, + matchedRequests, + transporter, + } = await this.prisma.trip.findUniqueOrThrow({ + where: { id }, + select: { + status: true, + matchedRequests: { + select: { + deliveryTime: true, + }, + }, + transporter: { + select: { + id: true, + firstTripDate: true, + }, + }, + }, + }); + + if (tripStatus !== TripStatusEnum.in_progress) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, + ); + } + + const allRequestsDelivered = matchedRequests.every( + (m) => m.deliveryTime !== null, + ); + if (!allRequestsDelivered) { + throw new BadRequestException(BadRequestMessages.CannotFinishTrip); + } + + // Update firstTripDate/lastTripDate + const updateTransporter: Prisma.TransporterUpdateInput = {}; + if (!transporter.firstTripDate) { + updateTransporter.firstTripDate = new Date(); + } else { + updateTransporter.lastTripDate = new Date(); + } + + return this.runner.run(async (tx) => { + // Update transporter + await tx.transporter.update({ + where: { id: transporter.id }, + data: updateTransporter, + }); + + return this.updateStatus(id, TripStatusEnum.completed, tx); + }); + } + + private async updateStatus( + id: string, + status: TripStatusEnum, + tx: PrismaTransaction = this.prisma, + ) { + return tx.trip.update({ + where: { id }, + data: { status }, + }); + } + + private async updatePackageStatus( + id: string, + status: PackageStatusEnum, + tx: PrismaTransaction, + ) { + return tx.package.update({ + where: { + id, + deletedAt: null, + }, + data: { + status, + }, + }); + } +} diff --git a/src/modules/trip/domain/matched-route-sorter.spec.ts b/src/modules/trip/domain/matched-route-sorter.spec.ts new file mode 100644 index 0000000..1df1a70 --- /dev/null +++ b/src/modules/trip/domain/matched-route-sorter.spec.ts @@ -0,0 +1,136 @@ +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PackageStatusEnum } from '../../../../generated/prisma'; +import { TurfService } from '../../turf/turf.service'; +import { MatchedRouteSorter } from './matched-route-sorter'; + +describe('MatchedRouteSorter', () => { + let sorter: MatchedRouteSorter; + let turfService: DeepMockProxy; + + const origin = { latitude: '35.65', longitude: '51.35' }; + const destination = { latitude: '35.95', longitude: '51.65' }; + + function matchedRequest( + id: string, + status: PackageStatusEnum, + overrides: Record = {}, + ) { + return { + package: { + id, + status, + originAddress: { latitude: '35.7', longitude: '51.4' }, + recipient: { address: { latitude: '35.8', longitude: '51.5' } }, + pickupAtOrigin: true, + deliveryAtDestination: true, + ...overrides, + }, + }; + } + + beforeEach(() => { + turfService = mockDeep(); + sorter = new MatchedRouteSorter(turfService); + }); + + describe('sort', () => { + it('should map pickup points for active packages and reorder by route', async () => { + const requests = [ + matchedRequest('package-1', PackageStatusEnum.matched), + matchedRequest('package-2', PackageStatusEnum.matched), + ]; + turfService.sortLocationsByRoute.mockReturnValue( + new Map([ + ['package-2', { latitude: 'x', longitude: 'y' }], + ['package-1', { latitude: 'x', longitude: 'y' }], + ]) as never, + ); + + const result = await sorter.sort(origin, destination, requests); + + expect(turfService.sortLocationsByRoute).toHaveBeenCalledWith( + origin, + destination, + new Map([ + ['package-1', { latitude: '35.7', longitude: '51.4' }], + ['package-2', { latitude: '35.7', longitude: '51.4' }], + ]), + ); + expect(result.map((r) => r.package.id)).toEqual([ + 'package-2', + 'package-1', + ]); + }); + + it('should map delivery points for matched packages without pickup', async () => { + const requests = [ + matchedRequest('package-1', PackageStatusEnum.matched, { + pickupAtOrigin: false, + }), + ]; + turfService.sortLocationsByRoute.mockReturnValue( + new Map([['package-1', { latitude: 'x', longitude: 'y' }]]) as never, + ); + + await sorter.sort(origin, destination, requests); + + expect(turfService.sortLocationsByRoute).toHaveBeenCalledWith( + origin, + destination, + new Map([['package-1', { latitude: '35.8', longitude: '51.5' }]]), + ); + }); + + it('should drop delivered, returned and cancelled packages from pickup stops', async () => { + const requests = [ + matchedRequest('package-1', PackageStatusEnum.delivered), + matchedRequest('package-2', PackageStatusEnum.returned), + matchedRequest('package-3', PackageStatusEnum.cancelled), + matchedRequest('package-4', PackageStatusEnum.matched), + ]; + turfService.sortLocationsByRoute.mockReturnValue(new Map() as never); + + const result = await sorter.sort(origin, destination, requests); + + const sentMap = turfService.sortLocationsByRoute.mock.calls[0][2] as Map< + string, + unknown + >; + expect(Array.from(sentMap.keys())).toEqual(['package-4']); + expect(result).toEqual([]); + }); + + it('should drop packages with neither pickup nor delivery stop', async () => { + const requests = [ + matchedRequest('package-1', PackageStatusEnum.in_transit, { + pickupAtOrigin: false, + deliveryAtDestination: false, + }), + ]; + turfService.sortLocationsByRoute.mockReturnValue(new Map() as never); + + const result = await sorter.sort(origin, destination, requests); + + const sentMap = turfService.sortLocationsByRoute.mock.calls[0][2] as Map< + string, + unknown + >; + expect(Array.from(sentMap.keys())).toEqual([]); + expect(result).toEqual([]); + }); + + it('should skip sorted ids with no matching request', async () => { + const requests = [matchedRequest('package-1', PackageStatusEnum.matched)]; + turfService.sortLocationsByRoute.mockReturnValue( + new Map([ + ['package-ghost', { latitude: 'x', longitude: 'y' }], + ['package-1', { latitude: '35.7', longitude: '51.4' }], + ]) as never, + ); + + const result = await sorter.sort(origin, destination, requests); + + expect(result.map((r) => r.package.id)).toEqual(['package-1']); + }); + }); +}); diff --git a/src/modules/trip/domain/matched-route-sorter.ts b/src/modules/trip/domain/matched-route-sorter.ts new file mode 100644 index 0000000..6a1af35 --- /dev/null +++ b/src/modules/trip/domain/matched-route-sorter.ts @@ -0,0 +1,65 @@ +import { PackageStatusEnum } from '../../../../generated/prisma'; +import { Location } from '../../map/map.types'; +import { TurfService } from '../../turf/turf.service'; + +/** + * Route-ordering for matched packages (Phase 4 Task 4). + * + * Extracted verbatim from `TripService.sortMatchedPackages`: builds a + * packageId → stop-location map (pickup point for not-yet-picked-up packages, + * delivery point for matched packages awaiting delivery), orders it along the + * trip route via Turf, and reorders the matched requests to match. + */ +export class MatchedRouteSorter { + constructor(private readonly turfService: TurfService) {} + + async sort( + origin: Location, + destination: Location, + matchedRequests: any[], + ): Promise { + const locationsMap = new Map( + matchedRequests + .map((m) => [ + m.package.id, + m.package.pickupAtOrigin && + m.package.status !== PackageStatusEnum.delivered && + m.package.status !== PackageStatusEnum.returned && + m.package.status !== PackageStatusEnum.cancelled + ? { + latitude: m.package.originAddress.latitude, + longitude: m.package.originAddress.longitude, + } + : m.package.deliveryAtDestination && + m.package.status === PackageStatusEnum.matched + ? { + latitude: m.package.recipient.address.latitude, + longitude: m.package.recipient.address.longitude, + } + : undefined, + ]) + .filter(([_, location]) => location !== undefined) as [ + string, + Location, + ][], + ); + + const sortedLocations = this.turfService.sortLocationsByRoute( + origin, + destination, + locationsMap, + ); + const sortedPackageIds = Array.from(sortedLocations.keys()); + + // Create the sorted array + const sortedRequests: any[] = []; + sortedPackageIds.forEach((packageId) => { + const request = matchedRequests.find((m) => m.package.id === packageId); + if (request) { + sortedRequests.push(request); + } + }); + + return sortedRequests; + } +} diff --git a/src/modules/trip/domain/trip-request.service.spec.ts b/src/modules/trip/domain/trip-request.service.spec.ts new file mode 100644 index 0000000..6477d04 --- /dev/null +++ b/src/modules/trip/domain/trip-request.service.spec.ts @@ -0,0 +1,507 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripRequestService } from './trip-request.service'; +import { S3Service } from '../../s3/s3.service'; +import { FinancialService } from '../../financial/financial.service'; +import { + PrismaClient, + TripStatusEnum, + RequestStatusEnum, + PackageStatusEnum, +} from '../../../../generated/prisma'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import * as utilities from '../../../common/utilities'; +import { TurfService } from '../../turf/turf.service'; +import { NotificationService } from '../../notification/notification.service'; + +jest.mock('../../../common/utilities', () => ({ + generateCode: jest.fn(() => 12345), + generateUniqueCode: jest.fn(() => '17571445988911932924'), +})); + +describe('TripRequestService', () => { + let service: TripRequestService; + let prisma: DeepMockProxy; + let financialService: DeepMockProxy; + let s3Service: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + let turfService: DeepMockProxy; + + const mockTrip = { + id: 'trip-123', + status: TripStatusEnum.scheduled, + } as any; + + const mockTripRequest = { + id: 'request-123', + tripId: 'trip-123', + packageId: 'package-123', + status: RequestStatusEnum.pending, + deviationDistanceKm: 10, + deviationDurationMin: 15, + deviationCost: 50000, + } as any; + + const mockMatchedRequest = { + id: 'matched-123', + tripId: 'trip-123', + packageId: 'package-123', + trackingCode: '17571445988911932924', + deliveryCode: '12345', + transporterNotes: [], + pickupTime: null, + deliveryTime: null, + } as any; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + financialService = mockDeep(); + s3Service = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + turfService = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + // Reset utility mocks to default values + (utilities.generateCode as jest.Mock).mockReturnValue(12345); + (utilities.generateUniqueCode as jest.Mock).mockReturnValue( + '17571445988911932924', + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TripRequestService, + { provide: PrismaService, useValue: prisma }, + { provide: FinancialService, useValue: financialService }, + { provide: S3Service, useValue: s3Service }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + { provide: TurfService, useValue: turfService }, + ], + }).compile(); + + service = module.get(TripRequestService); + }); + + describe('updateRequest', () => { + it('should reject request', async () => { + const updatedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.rejected, + }; + prisma.tripRequest.update.mockResolvedValue({ + ...updatedRequest, + package: { code: 'PKG-123', senderId: 'user-123' }, + trip: { code: 'TRIP-123' }, + }); + + const result = await service.updateRequest('request-123', { + status: RequestStatusEnum.rejected, + } as any); + + expect(result).toEqual(updatedRequest); + expect(prisma.tripRequest.update).toHaveBeenCalledWith({ + where: { id: 'request-123' }, + data: { status: RequestStatusEnum.rejected }, + include: { + package: { + select: { + code: true, + senderId: true, + }, + }, + trip: { + select: { + code: true, + }, + }, + }, + }); + }); + + it('should accept request and create matched request', async () => { + const acceptedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.accepted, + }; + + prisma.tripRequest.update.mockResolvedValue(acceptedRequest); + prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); + prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + totalDeviationDistanceKm: 0, + totalDeviationDurationMin: 0, + } as any); + prisma.trip.update.mockResolvedValue(mockTrip); + prisma.package.findFirstOrThrow.mockResolvedValue({ + breakdown: { baseCost: 100000, deviationCost: 0 }, + senderId: 'user-123', + } as any); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + financialService.createEscrow.mockResolvedValue({} as any); + + const result = await service.updateRequest('request-123', { + status: RequestStatusEnum.accepted, + transporterNotes: ['Note 1'], + } as any); + + expect(result).toEqual(acceptedRequest); + expect(prisma.matchedRequest.create).toHaveBeenCalledWith({ + data: { + requestId: 'request-123', + packageId: 'package-123', + tripId: 'trip-123', + trackingCode: mockMatchedRequest.trackingCode, + deliveryCode: mockMatchedRequest.deliveryCode, + transporterNotes: ['Note 1'], + }, + }); + expect(prisma.package.update).toHaveBeenCalledWith({ + where: { id: 'package-123' }, + data: { + status: PackageStatusEnum.matched, + finalPrice: { increment: 50000 }, + breakdown: expect.any(Object), + }, + }); + }); + + it('should treat a non-rejected status as accept', async () => { + const acceptedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.accepted, + }; + + prisma.tripRequest.update.mockResolvedValue(acceptedRequest); + prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); + prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + totalDeviationDistanceKm: 0, + totalDeviationDurationMin: 0, + } as any); + prisma.trip.update.mockResolvedValue(mockTrip); + prisma.package.findFirstOrThrow.mockResolvedValue({ + breakdown: { baseCost: 100000, deviationCost: 0 }, + senderId: 'user-123', + } as any); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + financialService.createEscrow.mockResolvedValue({} as any); + + const result = await service.updateRequest('request-123', { + status: 'unknown-status', + } as any); + + expect(result).toEqual(acceptedRequest); + expect(prisma.matchedRequest.create).toHaveBeenCalled(); + }); + + it('should handle null totals and breakdown in accept', async () => { + const acceptedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.accepted, + }; + + prisma.tripRequest.update.mockResolvedValue(acceptedRequest); + prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); + prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + totalDeviationDistanceKm: null, + totalDeviationDurationMin: null, + } as any); + prisma.trip.update.mockResolvedValue(mockTrip); + prisma.package.findFirstOrThrow.mockResolvedValue({ + breakdown: null, + senderId: 'user-123', + } as any); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + financialService.createEscrow.mockResolvedValue({} as any); + + const result = await service.updateRequest('request-123', { + status: RequestStatusEnum.accepted, + } as any); + + expect(result).toEqual(acceptedRequest); + expect(prisma.trip.update).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + data: { + totalDeviationDistanceKm: 10, // 0 + 10 + totalDeviationDurationMin: 15, // 0 + 15 + }, + }); + }); + + it('should handle escrow creation failure gracefully', async () => { + const acceptedRequest = { + ...mockTripRequest, + status: RequestStatusEnum.accepted, + }; + + prisma.tripRequest.update.mockResolvedValue(acceptedRequest); + prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); + prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); + prisma.trip.findUniqueOrThrow.mockResolvedValue({ + totalDeviationDistanceKm: 0, + totalDeviationDurationMin: 0, + } as any); + prisma.trip.update.mockResolvedValue(mockTrip); + prisma.package.findFirstOrThrow.mockResolvedValue({ + breakdown: { baseCost: 100000, deviationCost: 0 }, + senderId: 'user-123', + } as any); + prisma.package.update.mockResolvedValue({ + status: PackageStatusEnum.matched, + } as any); + + financialService.createEscrow.mockRejectedValue( + new Error('Insufficient balance'), + ); + + const result = await service.updateRequest('request-123', { + status: RequestStatusEnum.accepted, + } as any); + + expect(result).toEqual(acceptedRequest); + expect(financialService.createEscrow).toHaveBeenCalled(); + }); + + it('should run accept and reject inside TransactionRunner', async () => { + prisma.tripRequest.update.mockResolvedValue({ + ...mockTripRequest, + package: { code: 'PKG-123', senderId: 'user-123' }, + trip: { code: 'TRIP-123' }, + }); + + await service.updateRequest('request-123', { + status: RequestStatusEnum.rejected, + } as any); + + expect(runner.run).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAllTripRequests', () => { + it('should get all pending trip requests', async () => { + const requests = [mockTripRequest]; + prisma.tripRequest.findMany.mockResolvedValue(requests); + + const result = await service.getAllTripRequests('trip-123'); + + expect(result).toEqual(requests); + expect(prisma.tripRequest.findMany).toHaveBeenCalledWith({ + where: { tripId: 'trip-123', status: RequestStatusEnum.pending }, + orderBy: { createdAt: 'asc' }, + }); + }); + }); + + describe('getAllMatchedRequests', () => { + it('should get all matched requests for a trip', async () => { + const matchedRequests = [ + { + package: { + id: 'package-123', + sender: { firstName: 'Ahmad', lastName: 'Mohammadi' }, + items: ['Electronics'], + weight: 2.5, + picturesKey: ['key-1'], + }, + request: { offeredPrice: 75000 }, + transporterNotes: ['Handle with care'], + pickupTime: null, + deliveryTime: null, + paymentStatus: 'pending', + }, + ]; + + prisma.matchedRequest.findMany.mockResolvedValue(matchedRequests as any); + s3Service.generateGetPresignedUrl.mockResolvedValue( + 'https://s3.example.com/key-1', + ); + + const result = await service.getAllMatchedRequests('trip-123'); + + expect(result).toEqual([ + { + package: { + ...matchedRequests[0].package, + picturesUrl: ['https://s3.example.com/key-1'], + offeredPrice: 75000, + picturesKey: undefined, + }, + transporterNotes: ['Handle with care'], + pickupTime: null, + deliveryTime: null, + paymentStatus: 'pending', + request: undefined, + }, + ]); + expect(prisma.matchedRequest.findMany).toHaveBeenCalledWith({ + where: { tripId: 'trip-123' }, + orderBy: { updatedAt: 'desc' }, + select: { + package: { + select: { + id: true, + code: true, + sender: { + select: { + firstName: true, + lastName: true, + gender: true, + phoneNumber: true, + }, + }, + status: true, + items: true, + originAddress: true, + recipient: { select: { address: true } }, + weight: true, + dimensions: true, + packageValue: true, + isFragile: true, + isPerishable: true, + description: true, + pickupAtOrigin: true, + deliveryAtDestination: true, + preferredPickupTime: true, + preferredDeliveryTime: true, + picturesKey: true, + }, + }, + request: true, + transporterNotes: true, + pickupTime: true, + deliveryTime: true, + paymentStatus: true, + }, + }); + }); + + it('should return matched requests in route order when inOrder is true', async () => { + const matchedRequests = [ + { + package: { + id: 'package-1', + code: 'PKG-1', + sender: { + firstName: 'A', + lastName: 'A', + gender: 'male', + phoneNumber: '+1', + }, + status: PackageStatusEnum.matched, + items: ['Item1'], + originAddress: { + latitude: '35.7', + longitude: '51.4', + city: 'Tehran', + }, + recipient: { + address: { latitude: '35.8', longitude: '51.5', city: 'Tehran' }, + }, + weight: 1, + dimensions: { w: 1, h: 1, l: 1 } as any, + packageValue: 100, + isFragile: false, + isPerishable: false, + description: 'desc1', + pickupAtOrigin: true, + deliveryAtDestination: true, + preferredPickupTime: null, + preferredDeliveryTime: null, + picturesKey: ['k1'], + }, + request: { offeredPrice: 10000 }, + transporterNotes: [], + pickupTime: null, + deliveryTime: null, + paymentStatus: 'pending', + }, + { + package: { + id: 'package-2', + code: 'PKG-2', + sender: { + firstName: 'B', + lastName: 'B', + gender: 'female', + phoneNumber: '+2', + }, + status: PackageStatusEnum.matched, + items: ['Item2'], + originAddress: { + latitude: '35.6', + longitude: '51.3', + city: 'Tehran', + }, + recipient: { + address: { latitude: '35.9', longitude: '51.6', city: 'Tehran' }, + }, + weight: 2, + dimensions: { w: 2, h: 2, l: 2 } as any, + packageValue: 200, + isFragile: true, + isPerishable: false, + description: 'desc2', + pickupAtOrigin: true, + deliveryAtDestination: true, + preferredPickupTime: null, + preferredDeliveryTime: null, + picturesKey: ['k2'], + }, + request: { offeredPrice: 20000 }, + transporterNotes: [], + pickupTime: null, + deliveryTime: null, + paymentStatus: 'pending', + }, + ]; + + prisma.matchedRequest.findMany.mockResolvedValue(matchedRequests as any); + prisma.trip.findFirstOrThrow.mockResolvedValue({ + origin: { latitude: '35.65', longitude: '51.35', name: 'Origin' }, + destination: { + latitude: '35.95', + longitude: '51.65', + name: 'Destination', + }, + } as any); + + (turfService.sortLocationsByRoute as any).mockImplementationOnce( + (_o, _d, _locationsMap: Map) => + new Map([ + ['package-2', { latitude: '35.6', longitude: '51.3' }], + ['package-1', { latitude: '35.7', longitude: '51.4' }], + ]), + ); + + s3Service.generateGetPresignedUrl.mockImplementation( + async (key: string) => `https://s3.example.com/${key}`, + ); + + const result = await service.getAllMatchedRequests('trip-123', true); + + expect(result.map((r) => r.package.id)).toEqual([ + 'package-2', + 'package-1', + ]); + expect(result[0].package.picturesUrl).toEqual([ + 'https://s3.example.com/k2', + ]); + expect(result[1].package.picturesUrl).toEqual([ + 'https://s3.example.com/k1', + ]); + }); + }); +}); diff --git a/src/modules/trip/domain/trip-request.service.ts b/src/modules/trip/domain/trip-request.service.ts new file mode 100644 index 0000000..5769a6d --- /dev/null +++ b/src/modules/trip/domain/trip-request.service.ts @@ -0,0 +1,319 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { generateCode, generateUniqueCode } from '../../../common/utilities'; +import { UpdateRequestDto } from '../dto/update-request.dto'; +import { instanceToPlain, plainToInstance } from 'class-transformer'; +import { PriceBreakdownDto } from '../../package/dto/package-response.dto'; +import { + PackageStatusEnum, + RequestStatusEnum, +} from '../../../../generated/prisma'; +import { FinancialService } from '../../financial/financial.service'; +import { S3Service } from '../../s3/s3.service'; +import { TurfService } from '../../turf/turf.service'; +import { NotificationService } from '../../notification/notification.service'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; +import { MatchedRouteSorter } from './matched-route-sorter'; + +/** + * Trip-side request handling (Phase 4 Task 4). + * + * Owns `updateRequest` (accept/reject), the pending-request list and the + * matched-request list. All multi-statement writes run through + * `TransactionRunner` (no manual `.catch` — the global filter maps Prisma + * errors). Fix vs the original: the reject branch now writes through `tx` + * (it used the root client inside the transaction). + * + * Escrow semantics preserved: creation on accept is intentionally non-fatal + * (MVP — the request is still accepted), while release on delivery (in the + * application service) is fatal. + */ +@Injectable() +export class TripRequestService { + private readonly sorter: MatchedRouteSorter; + + constructor( + private prisma: PrismaService, + private financialService: FinancialService, + private s3Service: S3Service, + private notificationService: NotificationService, + private runner: TransactionRunner, + turfService: TurfService, + ) { + this.sorter = new MatchedRouteSorter(turfService); + } + + async updateRequest( + requestId: string, + { status, transporterNotes }: UpdateRequestDto, + ) { + if ( + (status as unknown as RequestStatusEnum) === RequestStatusEnum.rejected + ) { + return this.runner.run(async (tx) => { + const { + package: packageData, + trip, + ...request + } = await tx.tripRequest.update({ + where: { id: requestId }, + data: { status }, + include: { + package: { + select: { + code: true, + senderId: true, + }, + }, + trip: { + select: { + code: true, + }, + }, + }, + }); + + // Add reject request notification + await this.notificationService.create( + packageData.senderId, + { + packageId: request.packageId, + tripId: request.tripId, + content: getNotificationMessage( + NotificationMessages.TripRequestRejected, + { + packageCode: packageData.code, + tripCode: trip.code, + }, + ), + }, + tx, + ); + + return request; + }); + } + + return this.runner.run(async (tx) => { + const request = await tx.tripRequest.update({ + where: { id: requestId }, + data: { status }, + }); + + // Delete other sent requests for this package + await tx.tripRequest.updateMany({ + where: { + packageId: request.packageId, + NOT: { + id: request.id, + }, + }, + data: { + status: RequestStatusEnum.deleted, + }, + }); + + // Create MatchedRequest instance + const trackingCode = generateUniqueCode(); + const deliveryCode = generateCode().toString(); + await tx.matchedRequest.create({ + data: { + requestId: request.id, + packageId: request.packageId, + tripId: request.tripId, + trackingCode, + deliveryCode, + transporterNotes, + }, + }); + + // Update total deviation info in trip + const { totalDeviationDistanceKm, totalDeviationDurationMin } = + await tx.trip.findUniqueOrThrow({ + where: { id: request.tripId }, + }); + + const newTotalDeviationDistance = + (totalDeviationDistanceKm ?? 0) + request.deviationDistanceKm; + const newTotalDeviationDuration = + (totalDeviationDurationMin ?? 0) + request.deviationDurationMin; + + const { code: tripCode } = await tx.trip.update({ + where: { id: request.tripId }, + data: { + totalDeviationDistanceKm: newTotalDeviationDistance, + totalDeviationDurationMin: newTotalDeviationDuration, + }, + }); + + // Get package and Update its status and breakdown + const packageData = await tx.package.findFirstOrThrow({ + where: { id: request.packageId }, + select: { + code: true, + breakdown: true, + senderId: true, + }, + }); + + // Update breakdown + const breakdown = plainToInstance( + PriceBreakdownDto, + packageData?.breakdown, + ); + if (breakdown) { + breakdown.deviationCost = request.deviationCost ?? 0; + } + const plainBreakdown = instanceToPlain(breakdown); + + await tx.package.update({ + where: { id: request.packageId }, + data: { + status: PackageStatusEnum.matched, + finalPrice: { + increment: request.deviationCost, + }, + breakdown: plainBreakdown, + }, + }); + + // Create escrow if balance is enough + try { + await this.financialService.createEscrow({ + packageId: request.packageId, + tripId: request.tripId, + }); + } catch { + // Escrow creation failure is intentionally non-fatal here (MVP behavior); request is still accepted. + } + + // Add accept request notification + await this.notificationService.create( + packageData.senderId, + { + packageId: request.packageId, + tripId: request.tripId, + content: getNotificationMessage( + NotificationMessages.TripRequestAccepted, + { + packageCode: packageData.code, + tripCode, + }, + ), + }, + tx, + ); + + return request; + }); + } + + async getAllTripRequests(tripId: string) { + return this.prisma.tripRequest.findMany({ + where: { + tripId, + status: RequestStatusEnum.pending, + }, + orderBy: { + createdAt: 'asc', + }, + }); + } + + async getAllMatchedRequests(tripId: string, inOrder = false) { + const matchedRequests = await this.prisma.matchedRequest.findMany({ + where: { + tripId, + }, + select: { + package: { + select: { + id: true, + code: true, + sender: { + select: { + firstName: true, + lastName: true, + gender: true, + phoneNumber: true, + }, + }, + status: true, + items: true, + originAddress: true, + recipient: { + select: { + address: true, + }, + }, + weight: true, + dimensions: true, + packageValue: true, + isFragile: true, + isPerishable: true, + description: true, + pickupAtOrigin: true, + deliveryAtDestination: true, + preferredPickupTime: true, + preferredDeliveryTime: true, + picturesKey: true, + }, + }, + request: true, + transporterNotes: true, + pickupTime: true, + deliveryTime: true, + paymentStatus: true, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + let sortedMatchedRequests = matchedRequests.map((m) => ({ + ...m, + package: { + ...m.package, + items: instanceToPlain(m.package.items) as string[], + }, + })); + if (matchedRequests.length > 0 && inOrder) { + const trip = await this.prisma.trip.findFirstOrThrow({ + where: { id: tripId }, + select: { + origin: true, + destination: true, + }, + }); + sortedMatchedRequests = await this.sorter.sort( + trip.origin, + trip.destination, + sortedMatchedRequests, + ); + } + + return Promise.all( + sortedMatchedRequests.map(async (m) => { + const picturesKey = instanceToPlain(m.package.picturesKey) as string[]; + const picturePromises = picturesKey?.map((k) => + this.s3Service.generateGetPresignedUrl(k), + ); + const picturesUrl = await Promise.all(picturePromises); + return { + ...m, + package: { + ...m.package, + picturesUrl, + offeredPrice: m.request.offeredPrice, + picturesKey: undefined, + }, + request: undefined, + }; + }), + ); + } +} diff --git a/src/modules/trip/domain/trip-tracking.service.spec.ts b/src/modules/trip/domain/trip-tracking.service.spec.ts new file mode 100644 index 0000000..afa3215 --- /dev/null +++ b/src/modules/trip/domain/trip-tracking.service.spec.ts @@ -0,0 +1,398 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripTrackingService } from './trip-tracking.service'; +import { MapService } from '../../map/map.service'; +import { + PrismaClient, + TripStatusEnum, + PackageStatusEnum, +} from '../../../../generated/prisma'; +import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { + BadRequestMessages, + AuthMessages, +} from '../../../common/enums/messages.enum'; +import { TurfService } from '../../turf/turf.service'; +import { NotificationService } from '../../notification/notification.service'; + +describe('TripTrackingService', () => { + let service: TripTrackingService; + let prisma: DeepMockProxy; + let mapService: DeepMockProxy; + let turfService: DeepMockProxy; + let notificationService: DeepMockProxy; + let runner: DeepMockProxy; + + const mockTrip = { + id: 'trip-123', + status: TripStatusEnum.scheduled, + } as any; + + const trackingDto = { + latitude: '35.6892', + longitude: '51.3890', + city: 'Tehran', + routeName: 'Azadi Square', + description: 'Moving towards destination', + }; + + beforeEach(async () => { + jest.resetAllMocks(); + + prisma = mockDeep(); + mapService = mockDeep(); + turfService = mockDeep(); + notificationService = mockDeep(); + runner = mockDeep(); + + runner.run.mockImplementation((fn: any) => fn(prisma)); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TripTrackingService, + { provide: PrismaService, useValue: prisma }, + { provide: MapService, useValue: mapService }, + { provide: TurfService, useValue: turfService }, + { provide: NotificationService, useValue: notificationService }, + { provide: TransactionRunner, useValue: runner }, + ], + }).compile(); + + service = module.get(TripTrackingService); + }); + + describe('addTripNote', () => { + it('should add note to specific package', async () => { + const tripWithMatches = { + ...mockTrip, + matchedRequests: [ + { + packageId: 'package-123', + transporterNotes: [], + package: { senderId: 'user-123' }, + }, + ], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithMatches); + prisma.matchedRequest.update.mockResolvedValue({} as any); + + const result = await service.addTripNote( + 'trip-123', + 'Test note', + 'package-123', + ); + + expect(result.count).toBe(1); + expect(prisma.matchedRequest.update).toHaveBeenCalledWith({ + where: { tripId: 'trip-123', packageId: 'package-123' }, + data: { transporterNotes: ['Test note'] }, + }); + }); + + it('should throw when trip is completed', async () => { + const completedTrip = { + ...mockTrip, + status: TripStatusEnum.completed, + matchedRequests: [], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue(completedTrip); + + await expect( + service.addTripNote('trip-123', 'Test note'), + ).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, + ), + ); + }); + + it('should handle empty matched requests in addTripNote', async () => { + const tripWithoutMatches = { + ...mockTrip, + matchedRequests: [], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithoutMatches); + + const result = await service.addTripNote('trip-123', 'Test note'); + + expect(result.count).toBe(0); + }); + + it('should handle partial delivery failures in addTripNote', async () => { + jest.spyOn(Promise, 'allSettled').mockResolvedValue([ + { status: 'fulfilled', value: { id: 'matched-1' } as any }, + { status: 'rejected', reason: new Error('Database error') }, + { status: 'fulfilled', value: { id: 'matched-3' } as any }, + ]); + + const tripWithMatches = { + ...mockTrip, + matchedRequests: [ + { + packageId: 'package-1', + transporterNotes: [], + package: { senderId: 'user-1' }, + }, + { + packageId: 'package-2', + transporterNotes: [], + package: { senderId: 'user-2' }, + }, + { + packageId: 'package-3', + transporterNotes: [], + package: { senderId: 'user-3' }, + }, + ], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithMatches); + + const result = await service.addTripNote('trip-123', 'Broadcast note'); + + expect(result.count).toBe(2); + + jest.restoreAllMocks(); + }); + }); + + describe('updateTracking', () => { + it('should update tracking successfully', async () => { + const tripWithMatches = { + status: TripStatusEnum.in_progress, + matchedRequests: [{ id: 'matched-1' }, { id: 'matched-2' }], + }; + + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce( + tripWithMatches as any, + ); + prisma.trackingUpdate.createMany.mockResolvedValue({ count: 2 }); + + const result = await service.updateTracking('trip-123', trackingDto); + + expect(result.count).toBe(2); + expect(prisma.trackingUpdate.createMany).toHaveBeenCalledWith({ + data: [ + { matchedRequestId: 'matched-1', ...trackingDto }, + { matchedRequestId: 'matched-2', ...trackingDto }, + ], + }); + }); + + it('should default to the injected client when no tx is provided', async () => { + prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ + status: TripStatusEnum.in_progress, + matchedRequests: [], + } as any); + prisma.trackingUpdate.createMany.mockResolvedValue({ count: 0 }); + + const result = await service.updateTracking('trip-123', trackingDto); + + expect(result).toEqual({ count: 0 }); + expect(prisma.trip.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { id: 'trip-123' }, + select: { status: true, matchedRequests: true }, + }); + }); + }); + + describe('getTripTracking', () => { + it('should get trip tracking successfully', async () => { + const trackingUpdates = [ + { id: 'tracking-1', city: 'Tehran', createdAt: new Date() }, + ]; + prisma.trackingUpdate.findMany.mockResolvedValue(trackingUpdates as any); + + const result = await service.getTripTracking('trip-123', 'package-123'); + + expect(result).toEqual(trackingUpdates); + expect(prisma.trackingUpdate.findMany).toHaveBeenCalledWith({ + where: { + matchedRequest: { tripId: 'trip-123', packageId: 'package-123' }, + deletedAt: null, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + }); + + describe('rateTrip', () => { + const rateDto = { + tripId: 'trip-123', + packageId: 'package-123', + rate: 5, + comment: 'Great service!', + }; + + const mockRatingData = { + senderRating: null, + package: { + senderId: 'user-123', + status: PackageStatusEnum.delivered, + }, + trip: { + transporterId: 'transporter-123', + transporter: { + rate: 4.5, + rateCount: 10, + }, + }, + }; + + it('should rate trip successfully', async () => { + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + mockRatingData as any, + ); + prisma.transporter.update.mockResolvedValue({} as any); + prisma.matchedRequest.update.mockResolvedValue({ + senderRating: 5, + } as any); + + const result = await service.rateTrip('user-123', rateDto); + + expect(result.senderRating).toBe(5); + expect(prisma.transporter.update).toHaveBeenCalledWith({ + where: { id: 'transporter-123' }, + data: { + rate: (4.5 * 10 + 5) / 11, // New average + rateCount: 11, + }, + }); + }); + + it('should throw when user is not the sender', async () => { + const wrongUserRatingData = { + ...mockRatingData, + package: { ...mockRatingData.package, senderId: 'different-user' }, + }; + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + wrongUserRatingData as any, + ); + + await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( + new ForbiddenException(`${AuthMessages.EntityAccessDenied} package.`), + ); + }); + + it('should throw when trip already rated', async () => { + const alreadyRatedData = { + ...mockRatingData, + senderRating: 4, + }; + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + alreadyRatedData as any, + ); + + await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( + new BadRequestException(BadRequestMessages.AlreadyRatedTrip), + ); + }); + + it('should throw when package status is invalid', async () => { + const invalidStatusData = { + ...mockRatingData, + package: { + ...mockRatingData.package, + status: PackageStatusEnum.matched, + }, + }; + prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( + invalidStatusData as any, + ); + + await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( + new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.matched}*.`, + ), + ); + }); + }); + + describe('getDirections', () => { + it('should build sorted waypoints and fetch directions', async () => { + const origin = { + latitude: '35.60', + longitude: '51.30', + name: 'Start', + } as any; + + prisma.trip.findFirstOrThrow.mockResolvedValue({ + destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, + } as any); + + // Two matched requests with pickup and delivery points + prisma.matchedRequest.findMany.mockResolvedValue([ + { + package: { + id: 'package-1', + status: PackageStatusEnum.matched, + originAddress: { latitude: '35.70', longitude: '51.40' }, + recipient: { address: { latitude: '35.80', longitude: '51.50' } }, + pickupAtOrigin: true, + deliveryAtDestination: true, + }, + }, + { + package: { + id: 'package-2', + status: PackageStatusEnum.matched, + originAddress: { latitude: '35.65', longitude: '51.35' }, + recipient: { address: { latitude: '35.90', longitude: '51.60' } }, + pickupAtOrigin: true, + deliveryAtDestination: true, + }, + }, + ] as any); + + // First call used by the route sorter: return an ordering map keyed by package ids + (turfService.sortLocationsByRoute as any).mockImplementationOnce( + (_o, _d, _map) => + new Map([ + ['package-2', { latitude: '35.65', longitude: '51.35' }], + ['package-1', { latitude: '35.70', longitude: '51.40' }], + ]), + ); + + // Second call sorts the final waypoints array + (turfService.sortLocationsByRoute as any).mockImplementationOnce( + (_o, _d, waypoints: any[]) => waypoints, + ); + + const mockedDirections = { polyline: 'encoded-polyline' } as any; + mapService.getDirections.mockResolvedValue(mockedDirections); + + const result = await service.getDirections('trip-123', origin); + + expect(mapService.getDirections).toHaveBeenCalledWith({ + origin, + destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, + waypoints: [ + { latitude: '35.65', longitude: '51.35' }, + { latitude: '35.90', longitude: '51.60' }, + { latitude: '35.70', longitude: '51.40' }, + { latitude: '35.80', longitude: '51.50' }, + ], + }); + expect(result).toBe(mockedDirections); + }); + + it('should return empty object when trip has no matched requests', async () => { + const origin = { latitude: '35.60', longitude: '51.30' } as any; + + prisma.trip.findFirstOrThrow.mockResolvedValue({ + destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, + } as any); + prisma.matchedRequest.findMany.mockResolvedValue([]); + + const result = await service.getDirections('trip-123', origin); + + expect(result).toEqual({}); + expect(mapService.getDirections).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/trip/domain/trip-tracking.service.ts b/src/modules/trip/domain/trip-tracking.service.ts new file mode 100644 index 0000000..4936135 --- /dev/null +++ b/src/modules/trip/domain/trip-tracking.service.ts @@ -0,0 +1,341 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { MapService } from '../../map/map.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TransactionRunner } from '../../prisma/transaction-runner'; +import { + AuthMessages, + BadRequestMessages, +} from '../../../common/enums/messages.enum'; +import { + MatchedRequest, + PackageStatusEnum, + TripStatusEnum, +} from '../../../../generated/prisma'; +import { UpdateTrackingDto } from '../dto/update-tracking.dto'; +import { RateTripDto } from '../dto/rate-trip.dto'; +import { isNumber } from 'class-validator'; +import { TurfService } from '../../turf/turf.service'; +import { Location } from '../../map/map.types'; +import { NotificationService } from '../../notification/notification.service'; +import { + getNotificationMessage, + NotificationMessages, +} from '../../notification/notification-messages'; +import { PrismaTransaction } from '../../prisma/prisma.types'; +import { instanceToPlain, plainToInstance } from 'class-transformer'; +import { MatchedRouteSorter } from './matched-route-sorter'; + +/** + * Trip tracking, notes, rating and directions (Phase 4 Task 4). + * + * Owns `addTripNote`, `updateTracking`, `getTripTracking`, `rateTrip` and + * `getDirections`. All multi-statement writes run through `TransactionRunner` + * (no manual `.catch` — the global filter maps Prisma errors). `rateTrip` has + * no financial interplay (it only averages the transporter rating); the + * escrow interplay lives in `TripRequestService.updateRequest` (non-fatal + * creation) and `TripService.deliveryPackage` (fatal release). + */ +@Injectable() +export class TripTrackingService { + private readonly sorter: MatchedRouteSorter; + + constructor( + private prisma: PrismaService, + private mapService: MapService, + private turfService: TurfService, + private notificationService: NotificationService, + private runner: TransactionRunner, + ) { + this.sorter = new MatchedRouteSorter(turfService); + } + + async addTripNote( + tripId: string, + note: string, + packageId?: string, + tx: PrismaTransaction = this.prisma, + ) { + const trip = await tx.trip.findUniqueOrThrow({ + where: { + id: tripId, + }, + include: { + matchedRequests: { + where: { + packageId, + }, + include: { + package: { + select: { + code: true, + senderId: true, + }, + }, + }, + }, + transporter: { + select: { + user: { + select: { + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }); + + if (trip.status === TripStatusEnum.completed) { + throw new BadRequestException( + `${BadRequestMessages.BaseTripStatus}*${trip.status}*.`, + ); + } + + // If packageId included, the note will send for all matched requests within a trip. + const updatedMatchedRequestsPromises = trip.matchedRequests.map( + async (m) => { + // Push note + const oldNotes = + plainToInstance(Array, m.transporterNotes) ?? []; + oldNotes.push(note); + const plainNewNotes = instanceToPlain(oldNotes); + + // Add create package notification + await this.notificationService.create( + m.package.senderId, + { + packageId: m.packageId, + tripId: m.tripId, + content: getNotificationMessage( + NotificationMessages.NewTransporterNote, + { + packageCode: m.package.code, + tripCode: trip.code, + noteContent: note, + }, + ), + }, + tx, + ); + + return tx.matchedRequest.update({ + where: { + tripId, + packageId: m.packageId, + }, + data: { + transporterNotes: plainNewNotes, + }, + }); + }, + ); + + // Run queries parallel and Get 'resolved' promises's length + const results = await Promise.allSettled(updatedMatchedRequestsPromises); + const count = results.filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' && result.value !== null, + ).length; + + return { + count, + }; + } + + async updateTracking( + tripId: string, + trackingDto: UpdateTrackingDto, + tx: PrismaTransaction = this.prisma, + ) { + const trip = await tx.trip.findUniqueOrThrow({ + where: { id: tripId }, + select: { + status: true, + matchedRequests: true, + }, + }); + + const trackingUpdates = trip.matchedRequests.map((m) => ({ + matchedRequestId: m.id, + ...trackingDto, + })); + return tx.trackingUpdate.createMany({ + data: trackingUpdates, + }); + } + + async getTripTracking(tripId: string, packageId: string) { + return this.prisma.trackingUpdate.findMany({ + where: { + matchedRequest: { + tripId, + packageId, + }, + deletedAt: null, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async rateTrip( + userId: string, + { tripId, packageId, rate, comment }: RateTripDto, + ) { + const { + senderRating, + package: { senderId, status }, + trip: { + transporterId, + transporter: { rate: tRate, rateCount }, + }, + } = await this.prisma.matchedRequest.findUniqueOrThrow({ + where: { + tripId, + packageId, + }, + select: { + senderRating: true, + package: { + select: { + senderId: true, + status: true, + }, + }, + trip: { + select: { + transporterId: true, + transporter: { + select: { + rate: true, + rateCount: true, + }, + }, + }, + }, + }, + }); + + if (senderId !== userId) { + throw new ForbiddenException( + `${AuthMessages.EntityAccessDenied} package.`, + ); + } + + if (isNumber(senderRating)) { + throw new BadRequestException(BadRequestMessages.AlreadyRatedTrip); + } + + const isValidStatus = + status === PackageStatusEnum.delivered || + status === PackageStatusEnum.returned; + if (!isValidStatus) { + throw new BadRequestException( + `${BadRequestMessages.BasePackageStatus}*${status}*.`, + ); + } + + return this.runner.run(async (tx) => { + // Update transporter rate + const newRateCount = rateCount + 1; + const newRate = (tRate * rateCount + rate) / newRateCount; + + await tx.transporter.update({ + where: { + id: transporterId, + }, + data: { + rate: newRate, + rateCount: newRateCount, + }, + }); + + return tx.matchedRequest.update({ + where: { + tripId, + packageId, + }, + data: { + senderRating: rate, + senderComment: comment, + }, + }); + }); + } + + async getDirections(tripId: string, origin: Location) { + const { destination } = await this.prisma.trip.findFirstOrThrow({ + where: { id: tripId }, + select: { + destination: true, + }, + }); + + let matchedRequests = await this.prisma.matchedRequest.findMany({ + where: { + tripId, + }, + select: { + package: { + select: { + id: true, + status: true, + originAddress: true, + recipient: { + select: { + address: true, + }, + }, + pickupAtOrigin: true, + deliveryAtDestination: true, + }, + }, + }, + }); + + if (matchedRequests.length < 1) { + return {}; + } + + matchedRequests = await this.sorter.sort( + origin, + destination, + matchedRequests, + ); + + const waypoints = matchedRequests.flatMap((m) => + [ + m.package.pickupAtOrigin + ? { + latitude: m.package.originAddress.latitude, + longitude: m.package.originAddress.longitude, + } + : undefined, + m.package.deliveryAtDestination + ? { + latitude: m.package.recipient.address.latitude, + longitude: m.package.recipient.address.longitude, + } + : undefined, + ].filter((v) => v !== undefined), + ); + + const sortedWaypoints = this.turfService.sortLocationsByRoute( + origin, + destination, + waypoints, + ); + + return this.mapService.getDirections({ + origin, + destination, + waypoints: sortedWaypoints, + }); + } +} diff --git a/src/modules/trip/trip.controller.spec.ts b/src/modules/trip/trip.controller.spec.ts index dd9dbbd..46c063b 100644 --- a/src/modules/trip/trip.controller.spec.ts +++ b/src/modules/trip/trip.controller.spec.ts @@ -1,20 +1,30 @@ import { Test, TestingModule } from '@nestjs/testing'; import { TripController } from './trip.controller'; import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { TripService } from './trip.service'; +import { TripService } from './application/trip.service'; +import { TripRequestService } from './domain/trip-request.service'; +import { TripTrackingService } from './domain/trip-tracking.service'; import { AccessTokenGuard } from '../auth/guard/token.guard'; import { OwnershipGuard } from '../auth/guard/ownership.guard'; describe('TripController', () => { let controller: TripController; let service: DeepMockProxy; + let requestService: DeepMockProxy; + let trackingService: DeepMockProxy; beforeEach(async () => { service = mockDeep(); + requestService = mockDeep(); + trackingService = mockDeep(); const module: TestingModule = await Test.createTestingModule({ controllers: [TripController], - providers: [{ provide: TripService, useValue: service }], + providers: [ + { provide: TripService, useValue: service }, + { provide: TripRequestService, useValue: requestService }, + { provide: TripTrackingService, useValue: trackingService }, + ], }) .overrideGuard(AccessTokenGuard) .useValue({ canActivate: () => true }) @@ -28,4 +38,29 @@ describe('TripController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + it('should delegate request routes to TripRequestService', async () => { + await controller.getAllTripRequests('trip-123'); + expect(requestService.getAllTripRequests).toHaveBeenCalledWith('trip-123'); + + await controller.updateTripRequest('request-123', { + status: 'rejected', + } as any); + expect(requestService.updateRequest).toHaveBeenCalledWith('request-123', { + status: 'rejected', + }); + }); + + it('should delegate tracking routes to TripTrackingService', async () => { + await controller.getTripTracking('trip-123', 'package-123'); + expect(trackingService.getTripTracking).toHaveBeenCalledWith( + 'trip-123', + 'package-123', + ); + + await controller.rateTrip({ tripId: 'trip-123' } as any, 'user-123'); + expect(trackingService.rateTrip).toHaveBeenCalledWith('user-123', { + tripId: 'trip-123', + }); + }); }); diff --git a/src/modules/trip/trip.controller.ts b/src/modules/trip/trip.controller.ts index a1835d7..506be9a 100644 --- a/src/modules/trip/trip.controller.ts +++ b/src/modules/trip/trip.controller.ts @@ -13,7 +13,9 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { TripService } from './trip.service'; +import { TripService } from './application/trip.service'; +import { TripRequestService } from './domain/trip-request.service'; +import { TripTrackingService } from './domain/trip-tracking.service'; import { AccessTokenGuard } from '../auth/guard/token.guard'; import { CreateTripDto } from './dto/create-trip.dto'; import { @@ -49,7 +51,11 @@ import { CoordinateQueryDto } from '../map/coordinates-query.dto'; @Controller('trips') export class TripController { - constructor(private tripService: TripService) {} + constructor( + private tripService: TripService, + private requestService: TripRequestService, + private trackingService: TripTrackingService, + ) {} @ApiOperation({ summary: 'Create a new trip', @@ -168,7 +174,7 @@ export class TripController { }) @Get(':id/requests') async getAllTripRequests(@Param('id', ParseUUIDPipe) id: string) { - return this.tripService.getAllTripRequests(id); + return this.requestService.getAllTripRequests(id); } @ApiOperation({ @@ -186,7 +192,7 @@ export class TripController { @Param('id', ParseUUIDPipe) id: string, @Body() body: UpdateRequestDto, ) { - return this.tripService.updateRequest(id, body); + return this.requestService.updateRequest(id, body); } @ApiOperation({ @@ -211,7 +217,7 @@ export class TripController { @Query('inOrder', new ParseBoolPipe({ optional: true })) inOrder: boolean = false, ) { - return this.tripService.getAllMatchedRequests(id, inOrder); + return this.requestService.getAllMatchedRequests(id, inOrder); } @ApiOperation({ @@ -356,7 +362,7 @@ export class TripController { @Param('id', ParseUUIDPipe) tripId: string, @Body() body: AddNoteDto, ) { - return this.tripService.addTripNote(tripId, body.note, body.packageId); + return this.trackingService.addTripNote(tripId, body.note, body.packageId); } @ApiOperation({ @@ -373,7 +379,7 @@ export class TripController { @Param('id', ParseUUIDPipe) tripId: string, @Body() body: BroadcastNoteDto, ) { - return this.tripService.addTripNote(tripId, body.note); + return this.trackingService.addTripNote(tripId, body.note); } @ApiOperation({ @@ -392,7 +398,7 @@ export class TripController { @Param('id', ParseUUIDPipe) tripId: string, @Body() body: UpdateTrackingDto, ) { - return this.tripService.updateTracking(tripId, body); + return this.trackingService.updateTracking(tripId, body); } @ApiOperation({ @@ -414,7 +420,7 @@ export class TripController { @Param('tripId', ParseUUIDPipe) tripId: string, @Param('packageId', ParseUUIDPipe) packageId: string, ) { - return this.tripService.getTripTracking(tripId, packageId); + return this.trackingService.getTripTracking(tripId, packageId); } @ApiOperation({ @@ -431,7 +437,7 @@ export class TripController { @HttpCode(HttpStatus.OK) @Post('rate') async rateTrip(@Body() body: RateTripDto, @CurrentUser('id') userId: string) { - return this.tripService.rateTrip(userId, body); + return this.trackingService.rateTrip(userId, body); } @ApiOperation({ @@ -451,7 +457,7 @@ export class TripController { @Param('id', ParseUUIDPipe) id: string, @Query() origin: CoordinateQueryDto, ) { - return this.tripService.getDirections(id, { + return this.trackingService.getDirections(id, { latitude: origin.lat, longitude: origin.lng, }); diff --git a/src/modules/trip/trip.module.ts b/src/modules/trip/trip.module.ts index 3df7137..b731c2b 100644 --- a/src/modules/trip/trip.module.ts +++ b/src/modules/trip/trip.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { TripController } from './trip.controller'; -import { TripService } from './trip.service'; +import { TripService } from './application/trip.service'; +import { TripRequestService } from './domain/trip-request.service'; +import { TripTrackingService } from './domain/trip-tracking.service'; import { MapModule } from '../map/map.module'; import { PrismaModule } from '../prisma/prisma.module'; import { TokenModule } from '../token/token.module'; @@ -22,7 +24,7 @@ import { NotificationModule } from '../notification/notification.module'; NotificationModule, ], controllers: [TripController], - providers: [TripService], - exports: [TripService], + providers: [TripService, TripRequestService, TripTrackingService], + exports: [TripService, TripRequestService, TripTrackingService], }) export class TripModule {} diff --git a/src/modules/trip/trip.service.spec.ts b/src/modules/trip/trip.service.spec.ts deleted file mode 100644 index 9072a2f..0000000 --- a/src/modules/trip/trip.service.spec.ts +++ /dev/null @@ -1,1523 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { TripService } from './trip.service'; -import { S3Service } from '../s3/s3.service'; -import { FinancialService } from '../financial/financial.service'; -import { MapService } from '../map/map.service'; -import { - PrismaClient, - TripStatusEnum, - TripTypeEnum, - RequestStatusEnum, - PackageStatusEnum, -} from '../../../generated/prisma'; -import { DeepMockProxy, mockDeep } from 'jest-mock-extended'; -import { PrismaService } from '../prisma/prisma.service'; -import { BadRequestException, ForbiddenException } from '@nestjs/common'; -import { - BadRequestMessages, - AuthMessages, - TrackingMessages, -} from '../../common/enums/messages.enum'; -import * as utilities from '../../common/utilities'; -import { TurfService } from '../turf/turf.service'; -import { NotificationService } from '../notification/notification.service'; - -jest.mock('../../common/utilities', () => ({ - generateCode: jest.fn(() => 12345), - generateUniqueCode: jest.fn(() => '17571445988911932924'), - formatPrismaError: jest.fn(), -})); - -describe('TripService', () => { - let service: TripService; - let prisma: DeepMockProxy; - let mapService: DeepMockProxy; - let financialService: DeepMockProxy; - let s3Service: DeepMockProxy; - let turfService: DeepMockProxy; - let notificationService: DeepMockProxy; - - const mockVehicle = { - id: 'vehicle-123', - ownerId: 'transporter-123', - owner: { - userId: 'user-123', - }, - } as any; - - const mockCity = { - id: 'city-123', - name: 'Tehran', - latitude: '35.6892', - longitude: '51.3890', - } as any; - - const mockTrip = { - id: 'trip-123', - transporterId: 'transporter-123', - originId: 'city-origin', - destinationId: 'city-dest', - vehicleId: 'vehicle-123', - status: TripStatusEnum.scheduled, - origin: mockCity, - destination: mockCity, - waypoints: [], - vehicle: mockVehicle, - normalDistanceKm: 100, - normalDurationMin: 120, - totalDeviationDistanceKm: 0, - totalDeviationDurationMin: 0, - matchedRequests: [], - } as any; - - const mockCreateTripDto = { - originId: 'city-origin', - destinationId: 'city-dest', - vehicleId: 'vehicle-123', - departureTime: [new Date(), new Date(Date.now() + 3600000)] as [Date, Date], - maxPackageWeightGr: 5000, - restrictedItems: ['fragile'], - description: 'Test trip', - }; - - const mockTripRequest = { - id: 'request-123', - tripId: 'trip-123', - packageId: 'package-123', - status: RequestStatusEnum.pending, - deviationDistanceKm: 10, - deviationDurationMin: 15, - deviationCost: 50000, - } as any; - - const mockMatchedRequest = { - id: 'matched-123', - tripId: 'trip-123', - packageId: 'package-123', - trackingCode: '17571445988911932924', - deliveryCode: '12345', - transporterNotes: [], - pickupTime: null, - deliveryTime: null, - package: { - id: 'package-123', - status: PackageStatusEnum.matched, - senderId: 'user-123', - originAddress: { - latitude: '35.6892', - longitude: '51.3890', - city: 'Tehran', - }, - recipient: { - address: { - latitude: '35.7219', - longitude: '51.3347', - city: 'Tehran', - }, - }, - breakdown: { baseCost: 100000, deviationCost: 0 }, - }, - trip: { - status: TripStatusEnum.in_progress, - transporter: { - rate: 4.5, - rateCount: 10, - profilePictureKey: 'profile-pic-key', - user: { - firstName: 'Ahmad', - lastName: 'Mohammadi', - phoneNumber: '+989123456789', - }, - }, - vehicle: { - vehicleType: 'truck', - model: { brand: { name: 'Volvo' } }, - }, - }, - } as any; - - const trackingDto = { - latitude: '35.6892', - longitude: '51.3890', - city: 'Tehran', - routeName: 'Azadi Square', - description: 'Moving towards destination', - }; - - beforeEach(async () => { - jest.resetAllMocks(); - - prisma = mockDeep(); - mapService = mockDeep(); - financialService = mockDeep(); - s3Service = mockDeep(); - turfService = mockDeep(); - notificationService = mockDeep(); - - // Reset utility mocks to default values - (utilities.generateCode as jest.Mock).mockReturnValue(12345); - (utilities.generateUniqueCode as jest.Mock).mockReturnValue( - '17571445988911932924', - ); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TripService, - { provide: PrismaService, useValue: prisma }, - { provide: MapService, useValue: mapService }, - { provide: FinancialService, useValue: financialService }, - { provide: S3Service, useValue: s3Service }, - { provide: TurfService, useValue: turfService }, - { provide: NotificationService, useValue: notificationService }, - ], - }).compile(); - - service = module.get(TripService); - }); - - describe('create', () => { - it('should create trip successfully', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); - prisma.city.findUniqueOrThrow.mockResolvedValue(mockCity); - mapService.calculateDistance.mockResolvedValue({ - distance: 100, - duration: 120, - }); - prisma.trip.create.mockResolvedValue(mockTrip); - - const result = await service.create('user-123', mockCreateTripDto); - - expect(result).toEqual(mockTrip); - expect(prisma.vehicle.findFirst).toHaveBeenCalledWith({ - where: { id: 'vehicle-123', owner: { userId: 'user-123' } }, - }); - expect(mapService.calculateDistance).toHaveBeenCalled(); - expect(prisma.trip.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - transporterId: 'transporter-123', - originId: 'city-origin', - destinationId: 'city-dest', - vehicleId: 'vehicle-123', - tripType: TripTypeEnum.intercity, - normalDistanceKm: 100, - normalDurationMin: 120, - }), - }); - }); - - it('should create trip with waypoints', async () => { - const tripDtoWithWaypoints = { - ...mockCreateTripDto, - waypoints: [{ id: 'way-1', name: 'Waypoint 1' }], - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); - prisma.city.findUniqueOrThrow.mockResolvedValue(mockCity); - mapService.calculateDistance.mockResolvedValue({ - distance: 100, - duration: 120, - }); - prisma.trip.create.mockResolvedValue(mockTrip); - - await service.create('user-123', tripDtoWithWaypoints as any); - - expect(prisma.trip.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - waypoints: { - createMany: { - data: [{ id: 'way-1', name: 'Waypoint 1' }], - }, - }, - }), - }); - }); - - it('should throw when vehicle not found', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.vehicle.findFirst.mockResolvedValue(null); - - await expect( - service.create('user-123', mockCreateTripDto), - ).rejects.toThrow( - new ForbiddenException(`${AuthMessages.EntityAccessDenied} vehicle.`), - ); - }); - }); - - describe('getById', () => { - it('should get trip by id successfully', async () => { - const tripWithIncludes = { - ...mockTrip, - waypoints: [{ id: 'way-1', isVisible: true }], - }; - prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithIncludes); - - const result = await service.getById('trip-123'); - - expect(result).toEqual(tripWithIncludes); - expect(prisma.trip.findUniqueOrThrow).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - include: { - origin: true, - destination: true, - waypoints: { where: { isVisible: true } }, - vehicle: { - select: { - vehicleType: true, - model: { include: { brand: true } }, - manufactureYear: true, - color: true, - }, - }, - }, - }); - }); - }); - - describe('getAll', () => { - it('should get all trips with default status filter', async () => { - const trips = [mockTrip]; - prisma.trip.findMany.mockResolvedValue(trips); - - const result = await service.getAll('user-123'); - - expect(result).toEqual(trips); - expect(prisma.trip.findMany).toHaveBeenCalledWith({ - where: { - transporter: { userId: 'user-123' }, - status: { - in: [ - TripStatusEnum.scheduled, - TripStatusEnum.closed, - TripStatusEnum.delayed, - TripStatusEnum.in_progress, - ], - }, - deletedAt: null, - }, - include: { - origin: true, - destination: true, - waypoints: { where: { isVisible: true } }, - }, - orderBy: { createdAt: 'desc' }, - }); - }); - - it('should get all trips with custom status filter', async () => { - const trips = [mockTrip]; - prisma.trip.findMany.mockResolvedValue(trips); - - await service.getAll('user-123', [TripStatusEnum.scheduled]); - - expect(prisma.trip.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - status: { in: [TripStatusEnum.scheduled] }, - }), - }), - ); - }); - }); - - describe('update', () => { - const updateDto = { - maxPackageWeightGr: 6000, - description: 'Updated description', - }; - - it('should update trip successfully', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.scheduled, - } as any); - prisma.trip.update.mockResolvedValue({ ...mockTrip, ...updateDto }); - - const result = await service.update('trip-123', updateDto); - - expect(result).toEqual({ ...mockTrip, ...updateDto }); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: updateDto, - }); - }); - - it('should update trip with waypoints', async () => { - const updateDtoWithWaypoints = { - ...updateDto, - waypoints: [{ id: 'way-2', name: 'New waypoint' }], - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.scheduled, - } as any); - prisma.tripWaypoint.deleteMany.mockResolvedValue({ count: 1 }); - prisma.trip.update.mockResolvedValue(mockTrip); - - await service.update('trip-123', updateDtoWithWaypoints as any); - - expect(prisma.tripWaypoint.deleteMany).toHaveBeenCalledWith({ - where: { tripId: 'trip-123', isVisible: true }, - }); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: expect.objectContaining({ - waypoints: { - createMany: { - data: [{ id: 'way-2', name: 'New waypoint' }], - }, - }, - }), - }); - }); - - it('should throw when trip status is not scheduled', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.in_progress, - } as any); - - await expect(service.update('trip-123', updateDto)).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.in_progress}*.`, - ), - ); - }); - }); - - describe('delete', () => { - it('should delete trip successfully', async () => { - const deletedTrip = { ...mockTrip, deletedAt: new Date() }; - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.scheduled, - } as any); - prisma.trip.update.mockResolvedValue(deletedTrip); - - const result = await service.delete('trip-123'); - - expect(result).toEqual(deletedTrip); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { deletedAt: expect.any(Date) }, - }); - }); - - it('should throw when trip status is not scheduled', async () => { - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.completed, - } as any); - - await expect(service.delete('trip-123')).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, - ), - ); - }); - }); - - describe('getAllTripRequests', () => { - it('should get all pending trip requests', async () => { - const requests = [mockTripRequest]; - prisma.tripRequest.findMany.mockResolvedValue(requests); - - const result = await service.getAllTripRequests('trip-123'); - - expect(result).toEqual(requests); - expect(prisma.tripRequest.findMany).toHaveBeenCalledWith({ - where: { tripId: 'trip-123', status: RequestStatusEnum.pending }, - orderBy: { createdAt: 'asc' }, - }); - }); - }); - - describe('updateRequest', () => { - it('should reject request', async () => { - const updatedRequest = { - ...mockTripRequest, - status: RequestStatusEnum.rejected, - }; - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.tripRequest.update.mockResolvedValue({ - ...updatedRequest, - package: { code: 'PKG-123', senderId: 'user-123' }, - trip: { code: 'TRIP-123' }, - }); - - const result = await service.updateRequest('request-123', { - status: RequestStatusEnum.rejected, - } as any); - - expect(result).toEqual(updatedRequest); - expect(prisma.tripRequest.update).toHaveBeenCalledWith({ - where: { id: 'request-123' }, - data: { status: RequestStatusEnum.rejected }, - include: { - package: { - select: { - code: true, - senderId: true, - }, - }, - trip: { - select: { - code: true, - }, - }, - }, - }); - }); - - it('should accept request and create matched request', async () => { - const acceptedRequest = { - ...mockTripRequest, - status: RequestStatusEnum.accepted, - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.tripRequest.update.mockResolvedValue(acceptedRequest); - prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); - prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - totalDeviationDistanceKm: 0, - totalDeviationDurationMin: 0, - } as any); - prisma.trip.update.mockResolvedValue(mockTrip); - prisma.package.findFirstOrThrow.mockResolvedValue({ - breakdown: { baseCost: 100000, deviationCost: 0 }, - senderId: 'user-123', - } as any); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.matched, - } as any); - financialService.createEscrow.mockResolvedValue({} as any); - - const result = await service.updateRequest('request-123', { - status: RequestStatusEnum.accepted, - transporterNotes: ['Note 1'], - } as any); - - expect(result).toEqual(acceptedRequest); - expect(prisma.matchedRequest.create).toHaveBeenCalledWith({ - data: { - requestId: 'request-123', - packageId: 'package-123', - tripId: 'trip-123', - trackingCode: mockMatchedRequest.trackingCode, - deliveryCode: mockMatchedRequest.deliveryCode, - transporterNotes: ['Note 1'], - }, - }); - expect(prisma.package.update).toHaveBeenCalledWith({ - where: { id: 'package-123' }, - data: { - status: PackageStatusEnum.matched, - finalPrice: { increment: 50000 }, - breakdown: expect.any(Object), - }, - }); - }); - }); - - describe('toggleTripAccess', () => { - it('should toggle from scheduled to closed', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.scheduled, - } as any); - prisma.trip.update.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.closed, - }); - - const result = await service.toggleTripAccess('trip-123'); - - expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.closed }); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { status: TripStatusEnum.closed }, - }); - }); - - it('should toggle from closed to scheduled', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - status: TripStatusEnum.closed, - } as any); - prisma.trip.update.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.scheduled, - }); - - const result = await service.toggleTripAccess('trip-123'); - - expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.scheduled }); - }); - - it('should throw when trip status is invalid', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.completed, - } as any); - - await expect(service.toggleTripAccess('trip-123')).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, - ), - ); - }); - }); - - describe('startTrip', () => { - it('should start trip successfully', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.scheduled, - origin: mockCity, - } as any); - - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.scheduled, - matchedRequests: [{ id: 'matched-1' }, { id: 'matched-2' }], - } as any); - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.update.mockResolvedValueOnce({ - ...mockTrip, - status: TripStatusEnum.in_progress, - }); - prisma.trackingUpdate.createMany.mockResolvedValue({ count: 2 }); - - const result = await service.startTrip('trip-123'); - - expect(result).toEqual({ - ...mockTrip, - status: TripStatusEnum.in_progress, - }); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { status: TripStatusEnum.in_progress }, - }); - expect(prisma.trackingUpdate.createMany).toHaveBeenCalledWith({ - data: [ - { - matchedRequestId: 'matched-1', - city: 'Tehran', - description: TrackingMessages.TripStarted, - }, - { - matchedRequestId: 'matched-2', - city: 'Tehran', - description: TrackingMessages.TripStarted, - }, - ], - }); - }); - }); - - describe('pickupPackage', () => { - it('should pickup package successfully', async () => { - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( - mockMatchedRequest, - ); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.in_transit, - } as any); - prisma.matchedRequest.update.mockResolvedValue({ - pickupTime: new Date(), - } as any); - prisma.trackingUpdate.create.mockResolvedValue({} as any); - - const result = await service.pickupPackage('trip-123', 'package-123'); - - expect(result.packageStatus).toBe(PackageStatusEnum.in_transit); - expect(result.pickupTime).toBeInstanceOf(Date); - expect(prisma.trackingUpdate.create).toHaveBeenCalledWith({ - data: { - matchedRequestId: 'matched-123', - latitude: '35.6892', - longitude: '51.3890', - city: 'Tehran', - description: TrackingMessages.PackagePickedUp, - }, - }); - }); - - it('should throw when package status is invalid', async () => { - const invalidMatchedRequest = { - ...mockMatchedRequest, - package: { - ...mockMatchedRequest.package, - status: PackageStatusEnum.delivered, - }, - }; - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( - invalidMatchedRequest, - ); - - await expect( - service.pickupPackage('trip-123', 'package-123'), - ).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.delivered}*.`, - ), - ); - }); - - it('should throw when trip status is invalid', async () => { - const invalidMatchedRequest = { - ...mockMatchedRequest, - trip: { status: TripStatusEnum.completed }, - }; - - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValueOnce( - invalidMatchedRequest, - ); - - await expect( - service.pickupPackage('trip-123', 'package-123'), - ).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${PackageStatusEnum.matched}*.`, - ), - ); - }); - }); - - describe('deliveryPackage', () => { - const inTransitMatchedRequest = { - ...mockMatchedRequest, - package: { - ...mockMatchedRequest.package, - status: PackageStatusEnum.in_transit, - }, - }; - - it('should deliver package successfully', async () => { - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - inTransitMatchedRequest, - ); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.delivered, - } as any); - prisma.matchedRequest.update.mockResolvedValue({ - deliveryTime: new Date(), - } as any); - prisma.trackingUpdate.create.mockResolvedValue({} as any); - financialService.releaseEscrow.mockResolvedValue({} as any); - - const result = await service.deliveryPackage( - 'trip-123', - 'package-123', - '12345', - ); - - expect(result.packageStatus).toBe(PackageStatusEnum.delivered); - expect(result.deliveryTime).toBeInstanceOf(Date); - expect(financialService.releaseEscrow).toHaveBeenCalledWith( - 'package-123', - 'trip-123', - prisma, - ); - }); - - it('should throw when delivery code is wrong', async () => { - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - inTransitMatchedRequest, - ); - - await expect( - service.deliveryPackage('trip-123', 'package-123', '54321'), - ).rejects.toThrow( - new BadRequestException(BadRequestMessages.WrongDeliveryCode), - ); - }); - }); - - describe('finishTrip', () => { - it('should finish trip successfully', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.in_progress, - matchedRequests: [{ deliveryTime: new Date() }], - transporter: { - id: 'transporter-123', - firstTripDate: null, - }, - } as any); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.update.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.completed, - }); - prisma.transporter.update.mockResolvedValue({} as any); - - const result = await service.finishTrip('trip-123'); - - expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.completed }); - expect(prisma.transporter.update).toHaveBeenCalledWith({ - where: { id: 'transporter-123' }, - data: { firstTripDate: expect.any(Date) }, - }); - }); - - it('should finish trip successfully when transporter has firstTripDate', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.in_progress, - matchedRequests: [{ deliveryTime: new Date() }], - transporter: { - id: 'transporter-123', - firstTripDate: new Date('2023-01-01'), - }, - } as any); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.trip.update.mockResolvedValue({ - ...mockTrip, - status: TripStatusEnum.completed, - }); - prisma.transporter.update.mockResolvedValue({} as any); - - const result = await service.finishTrip('trip-123'); - - expect(result).toEqual({ ...mockTrip, status: TripStatusEnum.completed }); - expect(prisma.transporter.update).toHaveBeenCalledWith({ - where: { id: 'transporter-123' }, - data: { lastTripDate: expect.any(Date) }, - }); - }); - - it('should throw when not all packages are delivered', async () => { - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce({ - status: TripStatusEnum.in_progress, - matchedRequests: [{ deliveryTime: null }], - transporter: { - id: 'transporter-123', - firstTripDate: null, - }, - } as any); - - await expect(service.finishTrip('trip-123')).rejects.toThrow( - new BadRequestException(BadRequestMessages.CannotFinishTrip), - ); - }); - }); - - describe('addTripNote', () => { - it('should add note to specific package', async () => { - const tripWithMatches = { - ...mockTrip, - matchedRequests: [ - { - packageId: 'package-123', - transporterNotes: [], - package: { senderId: 'user-123' }, - }, - ], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithMatches); - prisma.matchedRequest.update.mockResolvedValue({} as any); - - const result = await service.addTripNote( - 'trip-123', - 'Test note', - 'package-123', - ); - - expect(result.count).toBe(1); - expect(prisma.matchedRequest.update).toHaveBeenCalledWith({ - where: { tripId: 'trip-123', packageId: 'package-123' }, - data: { transporterNotes: ['Test note'] }, - }); - }); - - it('should throw when trip is completed', async () => { - const completedTrip = { - ...mockTrip, - status: TripStatusEnum.completed, - matchedRequests: [], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValue(completedTrip); - - await expect( - service.addTripNote('trip-123', 'Test note'), - ).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${TripStatusEnum.completed}*.`, - ), - ); - }); - }); - - describe('getTripTracking', () => { - it('should get trip tracking successfully', async () => { - const trackingUpdates = [ - { id: 'tracking-1', city: 'Tehran', createdAt: new Date() }, - ]; - prisma.trackingUpdate.findMany.mockResolvedValue(trackingUpdates as any); - - const result = await service.getTripTracking('trip-123', 'package-123'); - - expect(result).toEqual(trackingUpdates); - expect(prisma.trackingUpdate.findMany).toHaveBeenCalledWith({ - where: { - matchedRequest: { tripId: 'trip-123', packageId: 'package-123' }, - deletedAt: null, - }, - orderBy: { createdAt: 'desc' }, - }); - }); - }); - - describe('rateTrip', () => { - const rateDto = { - tripId: 'trip-123', - packageId: 'package-123', - rate: 5, - comment: 'Great service!', - }; - - const mockRatingData = { - senderRating: null, - package: { - senderId: 'user-123', - status: PackageStatusEnum.delivered, - }, - trip: { - transporterId: 'transporter-123', - transporter: { - rate: 4.5, - rateCount: 10, - }, - }, - }; - - it('should rate trip successfully', async () => { - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - mockRatingData as any, - ); - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.transporter.update.mockResolvedValue({} as any); - prisma.matchedRequest.update.mockResolvedValue({ - senderRating: 5, - } as any); - - const result = await service.rateTrip('user-123', rateDto); - - expect(result.senderRating).toBe(5); - expect(prisma.transporter.update).toHaveBeenCalledWith({ - where: { id: 'transporter-123' }, - data: { - rate: (4.5 * 10 + 5) / 11, // New average - rateCount: 11, - }, - }); - }); - - it('should throw when user is not the sender', async () => { - const wrongUserRatingData = { - ...mockRatingData, - package: { ...mockRatingData.package, senderId: 'different-user' }, - }; - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - wrongUserRatingData as any, - ); - - await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( - new ForbiddenException(`${AuthMessages.EntityAccessDenied} package.`), - ); - }); - - it('should throw when trip already rated', async () => { - const alreadyRatedData = { - ...mockRatingData, - senderRating: 4, - }; - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - alreadyRatedData as any, - ); - - await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( - new BadRequestException(BadRequestMessages.AlreadyRatedTrip), - ); - }); - - it('should throw when package status is invalid', async () => { - const invalidStatusData = { - ...mockRatingData, - package: { - ...mockRatingData.package, - status: PackageStatusEnum.matched, - }, - }; - prisma.matchedRequest.findUniqueOrThrow.mockResolvedValue( - invalidStatusData as any, - ); - - await expect(service.rateTrip('user-123', rateDto)).rejects.toThrow( - new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${PackageStatusEnum.matched}*.`, - ), - ); - }); - }); - - describe('updateTracking', () => { - it('should update tracking successfully', async () => { - const tripWithMatches = { - status: TripStatusEnum.in_progress, - matchedRequests: [{ id: 'matched-1' }, { id: 'matched-2' }], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValueOnce( - tripWithMatches as any, - ); - prisma.trackingUpdate.createMany.mockResolvedValue({ count: 2 }); - - const result = await service.updateTracking('trip-123', trackingDto); - - expect(result.count).toBe(2); - expect(prisma.trackingUpdate.createMany).toHaveBeenCalledWith({ - data: [ - { matchedRequestId: 'matched-1', ...trackingDto }, - { matchedRequestId: 'matched-2', ...trackingDto }, - ], - }); - }); - }); - - describe('getMultipleById', () => { - it('should get multiple trips by ids', async () => { - const trips = [mockTrip]; - prisma.trip.findMany.mockResolvedValue(trips); - - const result = await service.getMultipleById(['trip-123', 'trip-456']); - - expect(result).toEqual(trips); - expect(prisma.trip.findMany).toHaveBeenCalledWith({ - where: { - id: { in: ['trip-123', 'trip-456'] }, - status: TripStatusEnum.scheduled, - }, - include: { - origin: true, - destination: true, - waypoints: true, - vehicle: { - include: { - model: { include: { brand: true } }, - }, - }, - matchedRequests: { - include: { - package: { - include: { - originAddress: true, - recipient: { include: { address: true } }, - }, - }, - }, - }, - }, - }); - }); - }); - - describe('getAllMatchedRequests', () => { - it('should get all matched requests for a trip', async () => { - const matchedRequests = [ - { - package: { - id: 'package-123', - sender: { firstName: 'Ahmad', lastName: 'Mohammadi' }, - items: ['Electronics'], - weight: 2.5, - picturesKey: ['key-1'], - }, - request: { offeredPrice: 75000 }, - transporterNotes: ['Handle with care'], - pickupTime: null, - deliveryTime: null, - paymentStatus: 'pending', - }, - ]; - - prisma.matchedRequest.findMany.mockResolvedValue(matchedRequests as any); - s3Service.generateGetPresignedUrl.mockResolvedValue( - 'https://s3.example.com/key-1', - ); - - const result = await service.getAllMatchedRequests('trip-123'); - - expect(result).toEqual([ - { - package: { - ...matchedRequests[0].package, - picturesUrl: ['https://s3.example.com/key-1'], - offeredPrice: 75000, - picturesKey: undefined, - }, - transporterNotes: ['Handle with care'], - pickupTime: null, - deliveryTime: null, - paymentStatus: 'pending', - request: undefined, - }, - ]); - expect(prisma.matchedRequest.findMany).toHaveBeenCalledWith({ - where: { tripId: 'trip-123' }, - orderBy: { updatedAt: 'desc' }, - select: { - package: { - select: { - id: true, - code: true, - sender: { - select: { - firstName: true, - lastName: true, - gender: true, - phoneNumber: true, - }, - }, - status: true, - items: true, - originAddress: true, - recipient: { select: { address: true } }, - weight: true, - dimensions: true, - packageValue: true, - isFragile: true, - isPerishable: true, - description: true, - pickupAtOrigin: true, - deliveryAtDestination: true, - preferredPickupTime: true, - preferredDeliveryTime: true, - picturesKey: true, - }, - }, - request: true, - transporterNotes: true, - pickupTime: true, - deliveryTime: true, - paymentStatus: true, - }, - }); - }); - - it('should return matched requests in route order when inOrder is true', async () => { - const matchedRequests = [ - { - package: { - id: 'package-1', - code: 'PKG-1', - sender: { - firstName: 'A', - lastName: 'A', - gender: 'male', - phoneNumber: '+1', - }, - status: PackageStatusEnum.matched, - items: ['Item1'], - originAddress: { - latitude: '35.7', - longitude: '51.4', - city: 'Tehran', - }, - recipient: { - address: { latitude: '35.8', longitude: '51.5', city: 'Tehran' }, - }, - weight: 1, - dimensions: { w: 1, h: 1, l: 1 } as any, - packageValue: 100, - isFragile: false, - isPerishable: false, - description: 'desc1', - pickupAtOrigin: true, - deliveryAtDestination: true, - preferredPickupTime: null, - preferredDeliveryTime: null, - picturesKey: ['k1'], - }, - request: { offeredPrice: 10000 }, - transporterNotes: [], - pickupTime: null, - deliveryTime: null, - paymentStatus: 'pending', - }, - { - package: { - id: 'package-2', - code: 'PKG-2', - sender: { - firstName: 'B', - lastName: 'B', - gender: 'female', - phoneNumber: '+2', - }, - status: PackageStatusEnum.matched, - items: ['Item2'], - originAddress: { - latitude: '35.6', - longitude: '51.3', - city: 'Tehran', - }, - recipient: { - address: { latitude: '35.9', longitude: '51.6', city: 'Tehran' }, - }, - weight: 2, - dimensions: { w: 2, h: 2, l: 2 } as any, - packageValue: 200, - isFragile: true, - isPerishable: false, - description: 'desc2', - pickupAtOrigin: true, - deliveryAtDestination: true, - preferredPickupTime: null, - preferredDeliveryTime: null, - picturesKey: ['k2'], - }, - request: { offeredPrice: 20000 }, - transporterNotes: [], - pickupTime: null, - deliveryTime: null, - paymentStatus: 'pending', - }, - ]; - - prisma.matchedRequest.findMany.mockResolvedValue(matchedRequests as any); - prisma.trip.findFirstOrThrow.mockResolvedValue({ - origin: { latitude: '35.65', longitude: '51.35', name: 'Origin' }, - destination: { - latitude: '35.95', - longitude: '51.65', - name: 'Destination', - }, - } as any); - - // First call: sortLocationsByRoute for map of package locations -> returns Map ordering by package-2 then package-1 - (turfService.sortLocationsByRoute as any).mockImplementationOnce( - (_o, _d, _locationsMap: Map) => - new Map([ - ['package-2', { latitude: '35.6', longitude: '51.3' }], - ['package-1', { latitude: '35.7', longitude: '51.4' }], - ]), - ); - - s3Service.generateGetPresignedUrl.mockImplementation( - async (key: string) => `https://s3.example.com/${key}`, - ); - - const result = await service.getAllMatchedRequests('trip-123', true); - - expect(result.map((r) => r.package.id)).toEqual([ - 'package-2', - 'package-1', - ]); - expect(result[0].package.picturesUrl).toEqual([ - 'https://s3.example.com/k2', - ]); - expect(result[1].package.picturesUrl).toEqual([ - 'https://s3.example.com/k1', - ]); - }); - }); - - describe('getDirections', () => { - it('should build sorted waypoints and fetch directions', async () => { - const origin = { - latitude: '35.60', - longitude: '51.30', - name: 'Start', - } as any; - - prisma.trip.findFirstOrThrow.mockResolvedValue({ - destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, - } as any); - - // Two matched requests with pickup and delivery points - prisma.matchedRequest.findMany.mockResolvedValue([ - { - package: { - id: 'package-1', - status: PackageStatusEnum.matched, - originAddress: { latitude: '35.70', longitude: '51.40' }, - recipient: { address: { latitude: '35.80', longitude: '51.50' } }, - pickupAtOrigin: true, - deliveryAtDestination: true, - }, - }, - { - package: { - id: 'package-2', - status: PackageStatusEnum.matched, - originAddress: { latitude: '35.65', longitude: '51.35' }, - recipient: { address: { latitude: '35.90', longitude: '51.60' } }, - pickupAtOrigin: true, - deliveryAtDestination: true, - }, - }, - ] as any); - - // First call used by sortMatchedPackages: return an ordering map keyed by package ids - (turfService.sortLocationsByRoute as any).mockImplementationOnce( - (_o, _d, _map) => - new Map([ - ['package-2', { latitude: '35.65', longitude: '51.35' }], - ['package-1', { latitude: '35.70', longitude: '51.40' }], - ]), - ); - - // Second call sorts the final waypoints array - (turfService.sortLocationsByRoute as any).mockImplementationOnce( - (_o, _d, waypoints: any[]) => waypoints, - ); - - const mockedDirections = { polyline: 'encoded-polyline' } as any; - mapService.getDirections.mockResolvedValue(mockedDirections); - - const result = await service.getDirections('trip-123', origin); - - expect(mapService.getDirections).toHaveBeenCalledWith({ - origin, - destination: { latitude: '35.95', longitude: '51.65', name: 'End' }, - waypoints: [ - { latitude: '35.65', longitude: '51.35' }, - { latitude: '35.90', longitude: '51.60' }, - { latitude: '35.70', longitude: '51.40' }, - { latitude: '35.80', longitude: '51.50' }, - ], - }); - expect(result).toBe(mockedDirections); - }); - }); - - describe('Error handling', () => { - it('should handle escrow creation failure gracefully', async () => { - const acceptedRequest = { - ...mockTripRequest, - status: RequestStatusEnum.accepted, - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.tripRequest.update.mockResolvedValue(acceptedRequest); - prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); - prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - totalDeviationDistanceKm: 0, - totalDeviationDurationMin: 0, - } as any); - prisma.trip.update.mockResolvedValue(mockTrip); - prisma.package.findFirstOrThrow.mockResolvedValue({ - breakdown: { baseCost: 100000, deviationCost: 0 }, - senderId: 'user-123', - } as any); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.matched, - } as any); - - financialService.createEscrow.mockRejectedValue( - new Error('Insufficient balance'), - ); - - const result = await service.updateRequest('request-123', { - status: RequestStatusEnum.accepted, - } as any); - - expect(result).toEqual(acceptedRequest); - expect(financialService.createEscrow).toHaveBeenCalled(); - }); - }); - - describe('Edge cases', () => { - it('should handle empty waypoints array in create', async () => { - const tripDtoWithEmptyWaypoints = { - ...mockCreateTripDto, - waypoints: undefined, - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.vehicle.findFirst.mockResolvedValue(mockVehicle); - prisma.city.findUniqueOrThrow.mockResolvedValue(mockCity); - mapService.calculateDistance.mockResolvedValue({ - distance: 100, - duration: 120, - }); - prisma.trip.create.mockResolvedValue(mockTrip); - - await service.create('user-123', tripDtoWithEmptyWaypoints); - - expect(prisma.trip.create).toHaveBeenCalledWith({ - data: expect.not.objectContaining({ - waypoints: expect.anything(), - }), - }); - }); - - it('should handle null breakdown in updateRequest', async () => { - const acceptedRequest = { - ...mockTripRequest, - status: RequestStatusEnum.accepted, - }; - - prisma.$transaction.mockImplementation(async (callback) => - callback(prisma), - ); - prisma.tripRequest.update.mockResolvedValue(acceptedRequest); - prisma.tripRequest.updateMany.mockResolvedValue({ count: 0 }); - prisma.matchedRequest.create.mockResolvedValue(mockMatchedRequest); - prisma.trip.findUniqueOrThrow.mockResolvedValue({ - totalDeviationDistanceKm: null, - totalDeviationDurationMin: null, - } as any); - prisma.trip.update.mockResolvedValue(mockTrip); - prisma.package.findFirstOrThrow.mockResolvedValue({ - breakdown: null, - senderId: 'user-123', - } as any); - prisma.package.update.mockResolvedValue({ - status: PackageStatusEnum.matched, - } as any); - financialService.createEscrow.mockResolvedValue({} as any); - - const result = await service.updateRequest('request-123', { - status: RequestStatusEnum.accepted, - } as any); - - expect(result).toEqual(acceptedRequest); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { - totalDeviationDistanceKm: 10, // 0 + 10 - totalDeviationDurationMin: 15, // 0 + 15 - }, - }); - }); - - it('should handle empty matched requests in addTripNote', async () => { - const tripWithoutMatches = { - ...mockTrip, - matchedRequests: [], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithoutMatches); - - const result = await service.addTripNote('trip-123', 'Test note'); - - expect(result.count).toBe(0); - }); - - it('should handle partial delivery failures in getAllMatchedRequests', async () => { - jest.spyOn(Promise, 'allSettled').mockResolvedValue([ - { status: 'fulfilled', value: { id: 'matched-1' } as any }, - { status: 'rejected', reason: new Error('Database error') }, - { status: 'fulfilled', value: { id: 'matched-3' } as any }, - ]); - - const tripWithMatches = { - ...mockTrip, - matchedRequests: [ - { - packageId: 'package-1', - transporterNotes: [], - package: { senderId: 'user-1' }, - }, - { - packageId: 'package-2', - transporterNotes: [], - package: { senderId: 'user-2' }, - }, - { - packageId: 'package-3', - transporterNotes: [], - package: { senderId: 'user-3' }, - }, - ], - }; - - prisma.trip.findUniqueOrThrow.mockResolvedValue(tripWithMatches); - - const result = await service.addTripNote('trip-123', 'Broadcast note'); - - expect(result.count).toBe(2); - - jest.restoreAllMocks(); - }); - }); - - describe('Private method tests', () => { - it('should update package status correctly', async () => { - const updatedPackage = { - id: 'package-123', - status: PackageStatusEnum.in_transit, - }; - prisma.package.update.mockResolvedValue(updatedPackage as any); - - const result = await service['updatePackageStatus']( - 'package-123', - PackageStatusEnum.in_transit, - ); - - expect(result).toEqual(updatedPackage); - expect(prisma.package.update).toHaveBeenCalledWith({ - where: { id: 'package-123', deletedAt: null }, - data: { status: PackageStatusEnum.in_transit }, - }); - }); - - it('should update trip status correctly', async () => { - const updatedTrip = { ...mockTrip, status: TripStatusEnum.completed }; - prisma.trip.update.mockResolvedValue(updatedTrip); - - const result = await service['updateStatus']( - 'trip-123', - TripStatusEnum.completed, - ); - - expect(result).toEqual(updatedTrip); - expect(prisma.trip.update).toHaveBeenCalledWith({ - where: { id: 'trip-123' }, - data: { status: TripStatusEnum.completed }, - }); - }); - }); -}); diff --git a/src/modules/trip/trip.service.ts b/src/modules/trip/trip.service.ts deleted file mode 100644 index 2556ae3..0000000 --- a/src/modules/trip/trip.service.ts +++ /dev/null @@ -1,1358 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, -} from '@nestjs/common'; -import { MapService } from '../map/map.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { - formatPrismaError, - generateCode, - generateUniqueCode, -} from '../../common/utilities'; -import { CreateTripDto } from './dto/create-trip.dto'; -import { - AuthMessages, - BadRequestMessages, - TrackingMessages, -} from '../../common/enums/messages.enum'; -import { - MatchedRequest, - PackageStatusEnum, - Prisma, - RequestStatusEnum, - TripStatusEnum, - TripTypeEnum, -} from '../../../generated/prisma'; -import { UpdateTripDto } from './dto/update-trip.dto'; -import { UpdateRequestDto } from './dto/update-request.dto'; -import { PrismaTransaction } from '../prisma/prisma.types'; -import { instanceToPlain, plainToInstance } from 'class-transformer'; -import { PriceBreakdownDto } from '../package/dto/package-response.dto'; -import { UpdateTrackingDto } from './dto/update-tracking.dto'; -import { FinancialService } from '../financial/financial.service'; -import { RateTripDto } from './dto/rate-trip.dto'; -import { isNumber } from 'class-validator'; -import { S3Service } from '../s3/s3.service'; -import { TurfService } from '../turf/turf.service'; -import { Location } from '../map/map.types'; -import { NotificationService } from '../notification/notification.service'; -import { - getNotificationMessage, - NotificationMessages, -} from '../notification/notification-messages'; - -@Injectable() -export class TripService { - constructor( - private prisma: PrismaService, - private mapService: MapService, - private financialService: FinancialService, - private s3Service: S3Service, - private turfService: TurfService, - private notificationService: NotificationService, - ) {} - - async create( - userId: string, - { - vehicleId, - waypoints, - originId, - destinationId, - ...tripDto - }: CreateTripDto, - tripType: TripTypeEnum = TripTypeEnum.intercity, - ) { - return this.prisma - .$transaction(async (tx) => { - const vehicle = await tx.vehicle.findFirst({ - where: { - id: vehicleId, - owner: { - userId, - }, - }, - }); - - if (!vehicle) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} vehicle.`, - ); - } - - const originCity = await tx.city.findUniqueOrThrow({ - where: { id: originId }, - }); - - const destinationCity = await tx.city.findUniqueOrThrow({ - where: { id: destinationId }, - }); - - const { distance, duration } = await this.mapService.calculateDistance({ - origin: { - latitude: originCity.latitude, - longitude: originCity.longitude, - }, - destination: { - latitude: destinationCity.latitude, - longitude: destinationCity.longitude, - }, - waypoints, - }); - - const tripData = { - transporterId: vehicle.ownerId, - originId, - destinationId, - vehicleId: vehicle.id, - tripType, - normalDistanceKm: distance, - normalDurationMin: duration, - ...tripDto, - } as Prisma.TripUncheckedCreateInput; - - if (waypoints) { - // TODO: Sort waypoints? - tripData.waypoints = { - createMany: { - data: waypoints, - }, - }; - } - - const trip = await tx.trip.create({ - data: tripData, - }); - - // Add create trip notification - await this.notificationService.create( - userId, - { - tripId: trip.id, - content: getNotificationMessage(NotificationMessages.TripCreated, { - tripCode: trip.code, - }), - }, - tx, - ); - - return trip; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getById(id: string) { - return this.prisma.trip - .findUniqueOrThrow({ - where: { id }, - include: { - origin: true, - destination: true, - waypoints: { - where: { - isVisible: true, - }, - }, - vehicle: { - select: { - vehicleType: true, - model: { - include: { - brand: true, - }, - }, - manufactureYear: true, - color: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getMultipleById(ids: string[], tx: PrismaTransaction = this.prisma) { - return tx.trip - .findMany({ - where: { - id: { - in: ids, - }, - status: TripStatusEnum.scheduled, - }, - include: { - origin: true, - destination: true, - waypoints: true, - vehicle: { - include: { - model: { - include: { - brand: true, - }, - }, - }, - }, - matchedRequests: { - include: { - package: { - include: { - originAddress: true, - recipient: { - include: { - address: true, - }, - }, - }, - }, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAll( - userId: string, - status: TripStatusEnum[] = [ - TripStatusEnum.scheduled, - TripStatusEnum.closed, - TripStatusEnum.delayed, - TripStatusEnum.in_progress, - ], - ) { - return this.prisma.trip - .findMany({ - where: { - transporter: { - userId, - }, - status: { - in: status, - }, - deletedAt: null, - }, - include: { - origin: true, - destination: true, - waypoints: { - where: { - isVisible: true, - }, - }, - }, - orderBy: { - createdAt: 'desc', - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async update(id: string, { waypoints, ...tripDto }: UpdateTripDto) { - return this.prisma - .$transaction(async (tx) => { - const { status } = await tx.trip.findUniqueOrThrow({ - where: { - id, - deletedAt: null, - }, - select: { - status: true, - }, - }); - - if (status !== TripStatusEnum.scheduled) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${status}*.`, - ); - } - - const tripData = tripDto as Prisma.TripUpdateInput; - - if (waypoints) { - tripData.waypoints = { - createMany: { - data: waypoints, - }, - }; - - await tx.tripWaypoint.deleteMany({ - where: { - tripId: id, - isVisible: true, - }, - }); - } - - return tx.trip.update({ - where: { id }, - data: tripData, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async delete(id: string) { - return this.prisma - .$transaction(async (tx) => { - const { status } = await tx.trip.findUniqueOrThrow({ - where: { - id, - deletedAt: null, - }, - select: { - status: true, - }, - }); - - if (status !== TripStatusEnum.scheduled) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${status}*.`, - ); - } - - return tx.trip.update({ - where: { id }, - data: { - deletedAt: new Date(), - }, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAllTripRequests(tripId: string) { - return this.prisma.tripRequest.findMany({ - where: { - tripId, - status: RequestStatusEnum.pending, - }, - orderBy: { - createdAt: 'asc', - }, - }); - } - - async updateRequest( - requestId: string, - { status, transporterNotes }: UpdateRequestDto, - ) { - if ( - (status as unknown as RequestStatusEnum) === RequestStatusEnum.rejected - ) { - return this.prisma - .$transaction(async (tx) => { - const { - package: packageData, - trip, - ...request - } = await this.prisma.tripRequest.update({ - where: { id: requestId }, - data: { status }, - include: { - package: { - select: { - code: true, - senderId: true, - }, - }, - trip: { - select: { - code: true, - }, - }, - }, - }); - - // Add reject request notification - await this.notificationService.create( - packageData.senderId, - { - packageId: request.packageId, - tripId: request.tripId, - content: getNotificationMessage( - NotificationMessages.TripRequestRejected, - { - packageCode: packageData.code, - tripCode: trip.code, - }, - ), - }, - tx, - ); - - return request; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - return this.prisma - .$transaction(async (tx) => { - const request = await tx.tripRequest.update({ - where: { id: requestId }, - data: { status }, - }); - - // Delete other sent requests for this package - await tx.tripRequest.updateMany({ - where: { - packageId: request.packageId, - NOT: { - id: request.id, - }, - }, - data: { - status: RequestStatusEnum.deleted, - }, - }); - - // Create MatchedRequest instance - const trackingCode = generateUniqueCode(); - const deliveryCode = generateCode().toString(); - await tx.matchedRequest.create({ - data: { - requestId: request.id, - packageId: request.packageId, - tripId: request.tripId, - trackingCode, - deliveryCode, - transporterNotes, - }, - }); - - // Update total deviation info in trip - const { totalDeviationDistanceKm, totalDeviationDurationMin } = - await tx.trip.findUniqueOrThrow({ - where: { id: request.tripId }, - }); - - const newTotalDeviationDistance = - (totalDeviationDistanceKm ?? 0) + request.deviationDistanceKm; - const newTotalDeviationDuration = - (totalDeviationDurationMin ?? 0) + request.deviationDurationMin; - - const { code: tripCode } = await tx.trip.update({ - where: { id: request.tripId }, - data: { - totalDeviationDistanceKm: newTotalDeviationDistance, - totalDeviationDurationMin: newTotalDeviationDuration, - }, - }); - - // Get package and Update its status and breakdown - const packageData = await tx.package.findFirstOrThrow({ - where: { id: request.packageId }, - select: { - code: true, - breakdown: true, - senderId: true, - }, - }); - - // Update breakdown - const breakdown = plainToInstance( - PriceBreakdownDto, - packageData?.breakdown, - ); - if (breakdown) { - breakdown.deviationCost = request.deviationCost ?? 0; - } - const plainBreakdown = instanceToPlain(breakdown); - - await tx.package.update({ - where: { id: request.packageId }, - data: { - status: PackageStatusEnum.matched, - finalPrice: { - increment: request.deviationCost, - }, - breakdown: plainBreakdown, - }, - }); - - // Create escrow if balance is enough - try { - await this.financialService.createEscrow({ - packageId: request.packageId, - tripId: request.tripId, - }); - } catch { - // Escrow creation failure is intentionally non-fatal here (MVP behavior); request is still accepted. - } - - // Add accept request notification - await this.notificationService.create( - packageData.senderId, - { - packageId: request.packageId, - tripId: request.tripId, - content: getNotificationMessage( - NotificationMessages.TripRequestAccepted, - { - packageCode: packageData.code, - tripCode, - }, - ), - }, - tx, - ); - - return request; - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getAllMatchedRequests(tripId: string, inOrder = false) { - const matchedRequests = await this.prisma.matchedRequest - .findMany({ - where: { - tripId, - }, - select: { - package: { - select: { - id: true, - code: true, - sender: { - select: { - firstName: true, - lastName: true, - gender: true, - phoneNumber: true, - }, - }, - status: true, - items: true, - originAddress: true, - recipient: { - select: { - address: true, - }, - }, - weight: true, - dimensions: true, - packageValue: true, - isFragile: true, - isPerishable: true, - description: true, - pickupAtOrigin: true, - deliveryAtDestination: true, - preferredPickupTime: true, - preferredDeliveryTime: true, - picturesKey: true, - }, - }, - request: true, - transporterNotes: true, - pickupTime: true, - deliveryTime: true, - paymentStatus: true, - }, - orderBy: { - updatedAt: 'desc', - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - let sortedMatchedRequests = matchedRequests.map((m) => ({ - ...m, - package: { - ...m.package, - items: instanceToPlain(m.package.items) as string[], - }, - })); - if (matchedRequests.length > 0 && inOrder) { - const trip = await this.prisma.trip.findFirstOrThrow({ - where: { id: tripId }, - select: { - origin: true, - destination: true, - }, - }); - sortedMatchedRequests = await this.sortMatchedPackages( - trip.origin, - trip.destination, - sortedMatchedRequests, - ); - } - - return Promise.all( - sortedMatchedRequests.map(async (m) => { - const picturesKey = instanceToPlain(m.package.picturesKey) as string[]; - const picturePromises = picturesKey?.map((k) => - this.s3Service.generateGetPresignedUrl(k), - ); - const picturesUrl = await Promise.all(picturePromises); - return { - ...m, - package: { - ...m.package, - picturesUrl, - offeredPrice: m.request.offeredPrice, - picturesKey: undefined, - }, - request: undefined, - }; - }), - ); - } - - private async sortMatchedPackages( - origin: Location, - destination: Location, - matchedRequests: any[], - ) { - const locationsMap = new Map( - matchedRequests - .map((m) => [ - m.package.id, - m.package.pickupAtOrigin && - m.package.status !== PackageStatusEnum.delivered && - m.package.status !== PackageStatusEnum.returned && - m.package.status !== PackageStatusEnum.cancelled - ? { - latitude: m.package.originAddress.latitude, - longitude: m.package.originAddress.longitude, - } - : m.package.deliveryAtDestination && - m.package.status === PackageStatusEnum.matched - ? { - latitude: m.package.recipient.address.latitude, - longitude: m.package.recipient.address.longitude, - } - : undefined, - ]) - .filter(([_, location]) => location !== undefined) as [ - string, - Location, - ][], - ); - - const sortedLocations = this.turfService.sortLocationsByRoute( - origin, - destination, - locationsMap, - ); - const sortedPackageIds = Array.from(sortedLocations.keys()); - - // Create the sorted array - const sortedRequests: any[] = []; - sortedPackageIds.forEach((packageId) => { - const request = matchedRequests.find((m) => m.package.id === packageId); - if (request) { - sortedRequests.push(request); - } - }); - - return sortedRequests; - } - - private async updateStatus( - id: string, - status: TripStatusEnum, - tx: PrismaTransaction = this.prisma, - ) { - return tx.trip.update({ - where: { id }, - data: { status }, - }); - } - - async toggleTripAccess(id: string) { - const { status: tripStatus } = await this.prisma.trip - .findUniqueOrThrow({ - where: { id }, - select: { - status: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - const isValidStatus = - tripStatus === TripStatusEnum.scheduled || - tripStatus === TripStatusEnum.closed; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, - ); - } - - const updatedStatus = - tripStatus === TripStatusEnum.scheduled - ? TripStatusEnum.closed - : TripStatusEnum.scheduled; - - return this.updateStatus(id, updatedStatus); - } - - async startTrip(id: string) { - const { status: tripStatus, origin } = await this.prisma.trip - .findUniqueOrThrow({ - where: { id }, - select: { - status: true, - origin: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - const isValidStatus = - tripStatus === TripStatusEnum.scheduled || - tripStatus === TripStatusEnum.closed || - tripStatus === TripStatusEnum.delayed; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, - ); - } - - return this.prisma - .$transaction(async (tx) => { - await this.updateTracking(id, { - city: origin.name, - description: TrackingMessages.TripStarted, - }); - - return this.updateStatus(id, TripStatusEnum.in_progress, tx); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async pickupPackage(tripId: string, packageId: string) { - const { - id: matchedRequestId, - package: packageData, - trip, - } = await this.prisma.matchedRequest - .findUniqueOrThrow({ - where: { - tripId, - packageId, - }, - select: { - id: true, - package: { - select: { - code: true, - senderId: true, - status: true, - originAddress: true, - }, - }, - trip: { - select: { - status: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (packageData.status !== PackageStatusEnum.matched) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${packageData.status}*.`, - ); - } - - if (trip.status !== TripStatusEnum.in_progress) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${packageData.status}*.`, - ); - } - - return this.prisma.$transaction(async (tx) => { - // Update package status - const { status: packageStatus } = await this.updatePackageStatus( - packageId, - PackageStatusEnum.in_transit, - ); - - // Set pickupTime - const { pickupTime } = await tx.matchedRequest.update({ - where: { - tripId, - packageId, - }, - data: { - pickupTime: new Date(), - }, - }); - - // Update tracking - await tx.trackingUpdate.create({ - data: { - matchedRequestId, - latitude: packageData.originAddress.latitude, - longitude: packageData.originAddress.longitude, - city: packageData.originAddress.city, - description: TrackingMessages.PackagePickedUp, - }, - }); - - // Add pickup package notification - await this.notificationService.create( - packageData.senderId, - { - packageId, - tripId, - content: getNotificationMessage( - NotificationMessages.PackagePickedUp, - { packageCode: packageData.code }, - ), - }, - tx, - ); - - return { - packageStatus, - pickupTime, - }; - }); - } - - async deliveryPackage(tripId: string, packageId: string, code: string) { - const { - id: matchedRequestId, - package: packageData, - trip, - deliveryCode, - } = await this.prisma.matchedRequest - .findUniqueOrThrow({ - where: { - tripId, - packageId, - }, - select: { - id: true, - package: { - select: { - code: true, - senderId: true, - status: true, - recipient: { - include: { - address: true, - }, - }, - }, - }, - trip: { - select: { - status: true, - }, - }, - deliveryCode: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (packageData.status !== PackageStatusEnum.in_transit) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${packageData.status}*.`, - ); - } - - if (trip.status !== TripStatusEnum.in_progress) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${trip.status}*.`, - ); - } - - if (code !== deliveryCode) { - throw new BadRequestException(BadRequestMessages.WrongDeliveryCode); - } - - return this.prisma.$transaction(async (tx) => { - // Update package status - const { status: packageStatus } = await this.updatePackageStatus( - packageId, - PackageStatusEnum.delivered, - ); - - // Set deliveryTime - const { deliveryTime } = await tx.matchedRequest.update({ - where: { - tripId, - packageId, - }, - data: { - deliveryTime: new Date(), - }, - }); - - // Update tracking - await tx.trackingUpdate.create({ - data: { - matchedRequestId, - latitude: packageData.recipient.address.latitude, - longitude: packageData.recipient.address.longitude, - city: packageData.recipient.address.city, - description: TrackingMessages.PackageDelivered, - }, - }); - - // Release escrow - await this.financialService.releaseEscrow(packageId, tripId, tx); - - // TODO: Send SMS - - // Add delivery package notification - await this.notificationService.create( - packageData.senderId, - { - packageId, - tripId, - content: getNotificationMessage( - NotificationMessages.PackageDelivered, - { packageCode: packageData.code }, - ), - }, - tx, - ); - - return { - packageStatus, - deliveryTime, - }; - }); - } - - async finishTrip(id: string) { - const { - status: tripStatus, - matchedRequests, - transporter, - } = await this.prisma.trip - .findUniqueOrThrow({ - where: { id }, - select: { - status: true, - matchedRequests: { - select: { - deliveryTime: true, - }, - }, - transporter: { - select: { - id: true, - firstTripDate: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (tripStatus !== TripStatusEnum.in_progress) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${tripStatus}*.`, - ); - } - - const allRequestsDelivered = matchedRequests.every( - (m) => m.deliveryTime !== null, - ); - if (!allRequestsDelivered) { - throw new BadRequestException(BadRequestMessages.CannotFinishTrip); - } - - // Update firstTripDate/lastTripDate - const updateTransporter: Prisma.TransporterUpdateInput = {}; - if (!transporter.firstTripDate) { - updateTransporter.firstTripDate = new Date(); - } else { - updateTransporter.lastTripDate = new Date(); - } - - return this.prisma.$transaction(async (tx) => { - // Update transporter - await tx.transporter.update({ - where: { id: transporter.id }, - data: updateTransporter, - }); - - return this.updateStatus(id, TripStatusEnum.completed, tx); - }); - } - - async addTripNote( - tripId: string, - note: string, - packageId?: string, - tx: PrismaTransaction = this.prisma, - ) { - const trip = await tx.trip - .findUniqueOrThrow({ - where: { - id: tripId, - }, - include: { - matchedRequests: { - where: { - packageId, - }, - include: { - package: { - select: { - code: true, - senderId: true, - }, - }, - }, - }, - transporter: { - select: { - user: { - select: { - firstName: true, - lastName: true, - }, - }, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (trip.status === TripStatusEnum.completed) { - throw new BadRequestException( - `${BadRequestMessages.BaseTripStatus}*${trip.status}*.`, - ); - } - - // If packageId included, the note will send for all matched requests within a trip. - const updatedMatchedRequestsPromises = trip.matchedRequests.map( - async (m) => { - // Push note - const oldNotes = - plainToInstance(Array, m.transporterNotes) ?? []; - oldNotes.push(note); - const plainNewNotes = instanceToPlain(oldNotes); - - // Add create package notification - await this.notificationService.create( - m.package.senderId, - { - packageId: m.packageId, - tripId: m.tripId, - content: getNotificationMessage( - NotificationMessages.NewTransporterNote, - { - packageCode: m.package.code, - tripCode: trip.code, - noteContent: note, - }, - ), - }, - tx, - ); - - return tx.matchedRequest.update({ - where: { - tripId, - packageId: m.packageId, - }, - data: { - transporterNotes: plainNewNotes, - }, - }); - }, - ); - - // Run queries parallel and Get 'resolved' promises's length - const results = await Promise.allSettled(updatedMatchedRequestsPromises); - const count = results.filter( - (result): result is PromiseFulfilledResult => - result.status === 'fulfilled' && result.value !== null, - ).length; - - return { - count, - }; - } - - async updateTracking( - tripId: string, - trackingDto: UpdateTrackingDto, - tx: PrismaTransaction = this.prisma, - ) { - const trip = await tx.trip - .findUniqueOrThrow({ - where: { id: tripId }, - select: { - status: true, - matchedRequests: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - const trackingUpdates = trip.matchedRequests.map((m) => ({ - matchedRequestId: m.id, - ...trackingDto, - })); - return tx.trackingUpdate.createMany({ - data: trackingUpdates, - }); - } - - async getTripTracking(tripId: string, packageId: string) { - return this.prisma.trackingUpdate - .findMany({ - where: { - matchedRequest: { - tripId, - packageId, - }, - deletedAt: null, - }, - orderBy: { - createdAt: 'desc', - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async rateTrip( - userId: string, - { tripId, packageId, rate, comment }: RateTripDto, - ) { - const { - senderRating, - package: { senderId, status }, - trip: { - transporterId, - transporter: { rate: tRate, rateCount }, - }, - } = await this.prisma.matchedRequest - .findUniqueOrThrow({ - where: { - tripId, - packageId, - }, - select: { - senderRating: true, - package: { - select: { - senderId: true, - status: true, - }, - }, - trip: { - select: { - transporterId: true, - transporter: { - select: { - rate: true, - rateCount: true, - }, - }, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (senderId !== userId) { - throw new ForbiddenException( - `${AuthMessages.EntityAccessDenied} package.`, - ); - } - - if (isNumber(senderRating)) { - throw new BadRequestException(BadRequestMessages.AlreadyRatedTrip); - } - - const isValidStatus = - status === PackageStatusEnum.delivered || - status === PackageStatusEnum.returned; - if (!isValidStatus) { - throw new BadRequestException( - `${BadRequestMessages.BasePackageStatus}*${status}*.`, - ); - } - - return this.prisma - .$transaction(async (tx) => { - // Update transporter rate - const newRateCount = rateCount + 1; - const newRate = (tRate * rateCount + rate) / newRateCount; - - await tx.transporter.update({ - where: { - id: transporterId, - }, - data: { - rate: newRate, - rateCount: newRateCount, - }, - }); - - return tx.matchedRequest.update({ - where: { - tripId, - packageId, - }, - data: { - senderRating: rate, - senderComment: comment, - }, - }); - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - } - - async getDirections(tripId: string, origin: Location) { - const { destination } = await this.prisma.trip - .findFirstOrThrow({ - where: { id: tripId }, - select: { - destination: true, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - let matchedRequests = await this.prisma.matchedRequest - .findMany({ - where: { - tripId, - }, - select: { - package: { - select: { - id: true, - status: true, - originAddress: true, - recipient: { - select: { - address: true, - }, - }, - pickupAtOrigin: true, - deliveryAtDestination: true, - }, - }, - }, - }) - .catch((error: Error) => { - formatPrismaError(error); - throw error; - }); - - if (matchedRequests.length < 1) { - return {}; - } - - matchedRequests = await this.sortMatchedPackages( - origin, - destination, - matchedRequests, - ); - - const waypoints = matchedRequests.flatMap((m) => - [ - m.package.pickupAtOrigin - ? { - latitude: m.package.originAddress.latitude, - longitude: m.package.originAddress.longitude, - } - : undefined, - m.package.deliveryAtDestination - ? { - latitude: m.package.recipient.address.latitude, - longitude: m.package.recipient.address.longitude, - } - : undefined, - ].filter((v) => v !== undefined), - ); - - const sortedWaypoints = this.turfService.sortLocationsByRoute( - origin, - destination, - waypoints, - ); - - return this.mapService.getDirections({ - origin, - destination, - waypoints: sortedWaypoints, - }); - } - - private async updatePackageStatus( - id: string, - status: PackageStatusEnum, - tx: PrismaTransaction = this.prisma, - ) { - return tx.package.update({ - where: { - id, - deletedAt: null, - }, - data: { - status, - }, - }); - } -} diff --git a/src/modules/turf/turf.service.geometry.spec.ts b/src/modules/turf/turf.service.geometry.spec.ts new file mode 100644 index 0000000..437e70a --- /dev/null +++ b/src/modules/turf/turf.service.geometry.spec.ts @@ -0,0 +1,172 @@ +import * as turf from '@turf/turf'; +import { TurfService } from './turf.service'; + +/** + * Geometry behavior with the real @turf/turf (Phase 4 Task 6). + * + * No mocks: asserts actual distances, direction checks and route sorting, + * including degenerate route inputs. + */ +describe('TurfService (real geometry)', () => { + let service: TurfService; + + beforeEach(() => { + service = new TurfService(turf); + }); + + describe('createPoint', () => { + it('should map string lat/lng to GeoJSON [lng, lat]', () => { + const point = service.createPoint({ + latitude: '35.6892', + longitude: '51.3890', + }); + + expect(point.geometry.coordinates).toEqual([51.389, 35.6892]); + }); + }); + + describe('createRoute', () => { + it('should build a 2-point line without waypoints', () => { + const route = service.createRoute( + { latitude: '0', longitude: '0' }, + { latitude: '0', longitude: '0.01' }, + ); + + expect(route.geometry.coordinates).toEqual([ + [0, 0], + [0.01, 0], + ]); + }); + + it('should splice waypoints between origin and destination', () => { + const route = service.createRoute( + { latitude: '0', longitude: '0' }, + { latitude: '0', longitude: '0.02' }, + [{ latitude: '0', longitude: '0.01' }], + ); + + expect(route.geometry.coordinates).toEqual([ + [0, 0], + [0.01, 0], + [0.02, 0], + ]); + }); + }); + + describe('getDistanceToRoute', () => { + const route = () => + service.createRoute( + { latitude: '0', longitude: '0' }, + { latitude: '0', longitude: '0.01' }, + ); + + it('should return near-zero for a point on the route', () => { + const distance = service.getDistanceToRoute( + service.createPoint({ latitude: '0', longitude: '0.005' }), + route(), + ); + + expect(distance).toBeLessThan(5); + }); + + it('should return a large distance for a far point', () => { + const distance = service.getDistanceToRoute( + service.createPoint({ latitude: '1', longitude: '1' }), + route(), + ); + + expect(distance).toBeGreaterThan(100_000); + }); + + it('should return a finite distance for a degenerate single-point route', () => { + const single = { latitude: '0', longitude: '0' }; + const distance = service.getDistanceToRoute( + service.createPoint({ latitude: '0', longitude: '0.001' }), + service.createRoute(single, single), + ); + + expect(Number.isFinite(distance)).toBe(true); + expect(distance).toBeGreaterThanOrEqual(0); + }); + }); + + describe('checkDirectionCompatibility', () => { + // NOTE: the implementation compares turf segment indices, so routes + // need at least 3 vertices for origin/destination to land on different + // segments (a 2-vertex route always compares 0 > 0). + const route = () => + service.createRoute( + { latitude: '0', longitude: '0' }, + { latitude: '0', longitude: '0.02' }, + [{ latitude: '0', longitude: '0.01' }], + ); + + it('should accept destination after origin along the route', () => { + expect( + service.checkDirectionCompatibility( + route(), + service.createPoint({ latitude: '0', longitude: '0.002' }), + service.createPoint({ latitude: '0', longitude: '0.015' }), + ), + ).toBe(true); + }); + + it('should reject destination before origin along the route', () => { + expect( + service.checkDirectionCompatibility( + route(), + service.createPoint({ latitude: '0', longitude: '0.015' }), + service.createPoint({ latitude: '0', longitude: '0.002' }), + ), + ).toBe(false); + }); + }); + + describe('sortLocationsByRoute', () => { + const origin = { latitude: '0', longitude: '0' }; + const destination = { latitude: '0', longitude: '0.02' }; + + it('should sort an array by distance from origin', () => { + const far = { latitude: '0', longitude: '0.015' }; + const near = { latitude: '0', longitude: '0.005' }; + + expect( + service.sortLocationsByRoute(origin, destination, [far, near]), + ).toEqual([near, far]); + }); + + it('should return a copy for zero or one locations', () => { + const single = [{ latitude: '0', longitude: '0.005' }]; + + expect(service.sortLocationsByRoute(origin, destination, [])).toEqual([]); + const sorted = service.sortLocationsByRoute(origin, destination, single); + expect(sorted).toEqual(single); + expect(sorted).not.toBe(single); + }); + + it('should sort a Map by distance from origin', () => { + const locations = new Map([ + ['far', { latitude: '0', longitude: '0.015' }], + ['near', { latitude: '0', longitude: '0.005' }], + ]); + + const sorted = service.sortLocationsByRoute( + origin, + destination, + locations, + ); + + expect(Array.from(sorted.keys())).toEqual(['near', 'far']); + }); + + it('should copy a Map with one or zero entries', () => { + const single = new Map([['only', { latitude: '0', longitude: '0.005' }]]); + + const sorted = service.sortLocationsByRoute(origin, destination, single); + expect(Array.from(sorted.entries())).toEqual( + Array.from(single.entries()), + ); + expect(sorted).not.toBe(single); + }); + }); +}); diff --git a/src/modules/turf/turf.service.spec.ts b/src/modules/turf/turf.service.spec.ts index 79ad3e7..43c3115 100644 --- a/src/modules/turf/turf.service.spec.ts +++ b/src/modules/turf/turf.service.spec.ts @@ -20,4 +20,51 @@ describe('TurfService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getDistanceToRoute', () => { + const point = { type: 'Point', coordinates: [51.389, 35.6892] } as never; + const route = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [51.38, 35.685], + [51.34, 35.725], + ], + }, + } as never; + + it('should return distance via nearestPointOnLine when it succeeds', () => { + const nearest = { type: 'Point', coordinates: [51.38, 35.685] } as never; + turfProvider.nearestPointOnLine.mockReturnValue(nearest); + turfProvider.distance.mockReturnValue(800 as never); + + expect(service.getDistanceToRoute(point, route)).toBe(800); + expect(turfProvider.nearestPointOnLine).toHaveBeenCalledWith( + route, + point, + ); + }); + + it('should log and fall back to vertex distances when nearestPointOnLine throws', () => { + turfProvider.nearestPointOnLine.mockImplementation(() => { + throw new Error('turf failed'); + }); + // Fake point: mock shapes are not real GeoJSON — assertion needed for tsc. + + turfProvider.point.mockImplementation( + (coords: number[]) => ({ coords }) as never, + ); + turfProvider.distance.mockReturnValue(600 as never); + const loggerSpy = jest + .spyOn((service as any).logger, 'error') + .mockImplementation(() => undefined); + + expect(service.getDistanceToRoute(point, route)).toBe(600); + expect(loggerSpy).toHaveBeenCalledWith( + 'Error calculating distance to route:', + expect.any(Error), + ); + }); + }); }); diff --git a/src/modules/turf/turf.service.ts b/src/modules/turf/turf.service.ts index a19d10a..6af1a5b 100644 --- a/src/modules/turf/turf.service.ts +++ b/src/modules/turf/turf.service.ts @@ -1,10 +1,12 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { Feature, LineString, Point } from 'geojson'; import { Turf, TURF_TOKEN } from './turf.provider'; import { Location } from '../map/map.types'; @Injectable() export class TurfService { + private readonly logger = new Logger(TurfService.name); + constructor(@Inject(TURF_TOKEN) private turf: Turf) {} // Helper method for create Point with location @@ -42,7 +44,7 @@ export class TurfService { const nearestPoint = this.turf.nearestPointOnLine(route, point); return this.turf.distance(point, nearestPoint, { units: 'meters' }); } catch (error) { - console.error('Error calculating distance to route:', error); + this.logger.error('Error calculating distance to route:', error); return this.getDistanceToRouteSimple(point, route); } } diff --git a/test/fixtures/index.ts b/test/fixtures/index.ts index 92979ba..305c3b9 100644 --- a/test/fixtures/index.ts +++ b/test/fixtures/index.ts @@ -8,3 +8,4 @@ export * from './user.fixture'; export * from './session.fixture'; export * from './location.fixture'; +export * from './match.fixture'; diff --git a/test/fixtures/match.fixture.ts b/test/fixtures/match.fixture.ts new file mode 100644 index 0000000..480f94b --- /dev/null +++ b/test/fixtures/match.fixture.ts @@ -0,0 +1,68 @@ +import { + MatchResult, + PackageWithLocations, + TripWithLocations, +} from '../../src/modules/package/matching.types'; +import { TripStatusEnum } from '../../generated/prisma'; +import { + DESTINATION_LOCATION, + ORIGIN_LOCATION, + createLocation, +} from './location.fixture'; + +/** Fresh TripWithLocations per call — never mutate the shared constant. */ +export const createTripWithLocations = ( + overrides: Partial = {}, +): TripWithLocations => ({ + id: 'trip-123', + status: TripStatusEnum.scheduled, + origin: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + destination: createLocation( + DESTINATION_LOCATION.latitude, + DESTINATION_LOCATION.longitude, + ), + waypoints: [], + ...overrides, +}); + +export const mockTripWithLocations = createTripWithLocations(); + +/** Fresh MatchResult per call. */ +export const createMatchResult = ( + overrides: Partial = {}, +): MatchResult => ({ + tripId: 'trip-123', + isRequestSent: false, + score: 0, + originDistance: 500, + destinationDistance: 300, + isOnCorridor: true, + ...overrides, +}); + +export const mockMatchResult = createMatchResult(); + +/** Fresh PackageWithLocations per call (cast: shares shape with Prisma Package). */ +export const createPackageWithLocations = ( + overrides: Partial = {}, +): PackageWithLocations => + ({ + id: 'package-123', + weight: 2000, + originAddress: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + recipient: { + address: createLocation( + ORIGIN_LOCATION.latitude, + ORIGIN_LOCATION.longitude, + ), + }, + ...overrides, + }) as unknown as PackageWithLocations; + +export const mockPackageWithLocations = createPackageWithLocations();