diff --git a/README.md b/README.md index 0fe9245..0140944 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,10 @@ sequenceDiagram - Real-time status updates during trip - Package pickup and delivery confirmations - Post-trip settlement and ratings +- Get directions to pickup/delivery each + +- **Get Trip Directions**: + - Retrieve directions from the transporter’s current location to the next pickup or delivery point for all packages, then to the trip’s final destination. diff --git a/src/modules/map/map.service.ts b/src/modules/map/map.service.ts index 41fc611..0d7f6ae 100644 --- a/src/modules/map/map.service.ts +++ b/src/modules/map/map.service.ts @@ -146,7 +146,7 @@ export class MapService { ); } - private async getDirections( + async getDirections( { vehicleType = 'car', tripType = 'intercity', diff --git a/src/modules/package/package.service.ts b/src/modules/package/package.service.ts index e89cb47..f3a65f2 100644 --- a/src/modules/package/package.service.ts +++ b/src/modules/package/package.service.ts @@ -485,7 +485,7 @@ export class PackageService { ].filter(v => v !== undefined) ); - const sortedWaypoints = this.turfService.sortLocationsByRoute(trip.origin, trip.destination, waypoints) + const sortedWaypoints = this.turfService.sortLocationsByRoute(trip.origin, trip.destination, waypoints); const { distance, duration } = await this.mapService.calculateDistance({ origin: { diff --git a/src/modules/trip/trip.controller.ts b/src/modules/trip/trip.controller.ts index ac93d90..717c3a4 100644 --- a/src/modules/trip/trip.controller.ts +++ b/src/modules/trip/trip.controller.ts @@ -33,6 +33,8 @@ import { DeliveryPackageDto } from './dto/delivery-package.dto'; import { BadRequestMessages } from '../../common/enums/messages.enum'; import { RateTripDto } from './dto/rate-trip.dto'; import { MatchedRequestResponseDto } from './dto/matched-request-response.dto'; +import { IsLatLong } from 'class-validator'; +import { CoordinateQueryDto } from '../map/coordinates-query.dto'; @Controller('trips') export class TripController { @@ -181,7 +183,8 @@ export class TripController { } @ApiOperation({ - summary: 'Get all trip matched requests by its id' + summary: 'Get all trip matched requests by its id', + description: 'When `inOrder` is true, results are returned in the sequence of each package’s pickup or delivery.' }) @ApiOkResponse({ type: MatchedRequestResponseDto @@ -419,4 +422,28 @@ export class TripController { ) { return this.tripService.rateTrip(userId, body); } + + + @ApiOperation({ + summary: 'Get directions for a trip', + description: `Retrieves directions from the transporter's current location + to the next pickup or delivery point for all packages in the trip, + followed by the trip's final destination.`, + }) + @AuthResponses() + @CrudResponses() + @UseGuards(AccessTokenGuard, OwnershipGuard) + @CheckOwnership({ + entity: 'trip' + }) + @Get(':id/directions') + async getTripDirections( + @Param('id', ParseUUIDPipe) id: string, + @Query() origin: CoordinateQueryDto + ) { + return this.tripService.getDirections(id, { + latitude: origin.lat, + longitude: origin.lng + }); + } } diff --git a/src/modules/trip/trip.service.spec.ts b/src/modules/trip/trip.service.spec.ts index 07fa0c7..96e45dc 100644 --- a/src/modules/trip/trip.service.spec.ts +++ b/src/modules/trip/trip.service.spec.ts @@ -955,7 +955,7 @@ describe('TripService', () => { status: true, items: true, originAddress: true, - recipient: true, + recipient: { select: { address: true } }, weight: true, dimensions: true, packageValue: true, @@ -977,6 +977,149 @@ describe('TripService', () => { } }); }); + + 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', () => { diff --git a/src/modules/trip/trip.service.ts b/src/modules/trip/trip.service.ts index 3c4b705..1130fc0 100644 --- a/src/modules/trip/trip.service.ts +++ b/src/modules/trip/trip.service.ts @@ -503,7 +503,11 @@ export class TripService { status: true, items: true, originAddress: true, - recipient: true, + recipient: { + select: { + address: true + } + }, weight: true, dimensions: true, packageValue: true, @@ -1144,6 +1148,69 @@ export class TripService { }); } + 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,