Files
edr-platform/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts
Nathnael 87e2edcbde fix(auth): stop class guards shadowing route permission keys
Nest runs class and method guards together, so a class-level view key
ANDs with every action key below it. Staff granted only an action were
denied before their key was ever checked: OCC could not fulfil wagon
transfers, dispatchers could not create a yard, and track staff could not
assign first/last-mile vehicles. Reads now carry the view key themselves,
and the warehouses baseline lists every key its routes use.
2026-08-07 07:40:50 +00:00

193 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
import { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-times.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
// No class-level key: Nest stacks class and method guards, so a class-level
// `view` would AND with every action key below and lock out staff granted only
// an action (e.g. assign_vehicles). Each route carries its own key instead.
export class LastMileController {
constructor(
private readonly lastMileService: LastMileService,
private readonly lastMileInvoiceService: LastMileInvoiceService,
) {}
@Get()
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: 'List last-mile legs' })
findAll(
@Query('status') status?: string,
@Query('bookingId') bookingId?: string,
@Query('vehicleId') vehicleId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.lastMileService.findAll({
status: status as LastMileStatus | undefined,
bookingId,
vehicleId,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: 'Get a last-mile leg by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.findById(id);
}
@Get('booking/:bookingId/arrival-trucks')
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: "Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill)" })
arrivalTrucks(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.lastMileService.arrivalTrucksForBooking(bookingId);
}
@Get('booking/:bookingId/remaining-tons')
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total departed trucks)' })
remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.lastMileService.remainingTonsForBooking(bookingId);
}
@Post('accept/:reference')
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
acceptBooking(@Param('reference') reference: string) {
return this.lastMileService.acceptBookingByReference(reference);
}
@Post()
@BookingStaff(FREIGHT_PERMS.lastMile.create)
@ApiOperation({ summary: 'Create a last-mile leg' })
create(@Body() dto: CreateLastMileDto) {
return this.lastMileService.create(dto);
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({ summary: 'Update a last-mile leg' })
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
// No invoice side-effects here — invoices are generated only via the
// explicit POST :id/invoice endpoint (the "Generate Invoice" action).
return this.lastMileService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.lastMile.delete)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a last-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.remove(id);
}
@Post(':id/vehicles')
@BookingStaff(FREIGHT_PERMS.lastMile.assignVehicles)
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
async setVehicles(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetVehiclesDto,
) {
return this.lastMileService.setVehicles(id, dto.vehicles);
}
@Post(':id/distances')
@BookingStaff(FREIGHT_PERMS.lastMile.setDistances)
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
async setDistances(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetDistancesDto,
) {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Post(':id/detention-times')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({
summary: 'Set each truck\'s own detention window (arrived at destination / returned)',
})
async setDetentionTimes(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetDetentionTimesDto,
) {
return this.lastMileService.setDetentionTimes(id, dto.trucks);
}
@Post(':id/warehouse-gate-times')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({
summary: 'Set each truck\'s warehouse gate arrival/departure times',
})
async setWarehouseGateTimes(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetWarehouseGateTimesDto,
) {
return this.lastMileService.setWarehouseGateTimes(id, dto.trucks);
}
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Record proof of delivery (signature + photos) and complete the leg' })
async recordProofOfDelivery(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RecordProofOfDeliveryDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.lastMileService.recordProofOfDelivery(id, dto, files ?? []);
}
@Post(':id/invoice')
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
const record = await this.lastMileService.findById(id);
const invoice = await this.lastMileInvoiceService.ensureInvoiceFor(record);
if (!invoice) {
throw new BadRequestException(
'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a LAST_MILE rate is configured, and the booking has a company.',
);
}
return invoice;
}
}