Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>

Expand Down
2 changes: 1 addition & 1 deletion src/modules/map/map.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export class MapService {
);
}

private async getDirections(
async getDirections(
{
vehicleType = 'car',
tripType = 'intercity',
Expand Down
2 changes: 1 addition & 1 deletion src/modules/package/package.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
29 changes: 28 additions & 1 deletion src/modules/trip/trip.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
});
}
}
145 changes: 144 additions & 1 deletion src/modules/trip/trip.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,7 @@ describe('TripService', () => {
status: true,
items: true,
originAddress: true,
recipient: true,
recipient: { select: { address: true } },
weight: true,
dimensions: true,
packageValue: true,
Expand All @@ -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<string, any>) =>
new Map<string, any>([
['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<string, any>([
['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', () => {
Expand Down
69 changes: 68 additions & 1 deletion src/modules/trip/trip.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading