Files
edr-platform/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.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

177 lines
5.9 KiB
TypeScript

import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import {
WagonTransferCancel,
WagonTransferCloseShort,
WagonTransferFulfill,
WagonTransferHistoryAll,
WagonTransferRequest,
WagonTransferView,
} from '../../common/booking-guards';
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
/** Query-string number, or undefined when absent/garbage (service defaults it). */
const toInt = (value?: string): number | undefined => {
const n = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
};
/**
* The wagon-transfer desk. A requester (transfer_request) files a count-only
* request; OCC (transfer_fulfill) moves wagons against it in as many
* instalments as the source yard allows, and closes it short
* (transfer_close_short) when the yard has no more to give. Separate top-level
* path so it never collides with `wagons/:id`.
*/
@ApiTags('wagon-transfer-requests')
@Controller('wagon-transfer-requests')
// No class-level key: Nest stacks class and method guards, so a class-level
// `transfer_view` would AND with every action key below and lock out the OCC
// staff granted only `transfer_fulfill`. Reads carry the view key themselves.
export class WagonTransferRequestsController {
constructor(private readonly service: WagonTransferRequestsService) {}
@Post()
@WagonTransferRequest()
@ApiOperation({ summary: 'File a count-only wagon-transfer request' })
create(
@Body() dto: CreateTransferRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.createRequest(dto, user?.id);
}
@Get()
@WagonTransferView()
@ApiOperation({
summary:
'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type',
})
list(@Query() query: ListTransferRequestsQueryDto) {
return this.service.listRequests(query);
}
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
// — Express matches in declaration order, so they would otherwise be captured
// by the `:id` param route (and rejected by ParseUUIDPipe).
@Post('bulk-fulfill')
@WagonTransferFulfill()
@ApiOperation({
summary:
'OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)',
})
bulkFulfill(
@Body() dto: BulkFulfillTransferRequestsDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.bulkFulfill(dto.requestIds, user?.id);
}
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
// matches in declaration order, so `/history` would otherwise be captured by
// the `:id` param route (and rejected by ParseUUIDPipe).
@Get('history')
@WagonTransferView()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiOperation({
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
})
myHistory(
@CurrentUser() user: TCurrentUser,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
// Never fall through to the all-staff view: getHistory(undefined) means
// "everyone", so a missing caller id must return empty, not leak scope.
if (!user?.id) {
return {
requests: [],
movements: [],
meta: {
page: 1,
pageSize: 20,
requestsTotal: 0,
movementsTotal: 0,
totalPages: 1,
},
};
}
return this.service.getHistory(user.id, toInt(page), toInt(pageSize));
}
@Get('history/all')
@WagonTransferHistoryAll()
@ApiQuery({ name: 'userId', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiOperation({
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
})
allHistory(
@Query('userId') userId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.service.getHistory(userId, toInt(page), toInt(pageSize));
}
@Get(':id')
@WagonTransferView()
@ApiOperation({ summary: 'Get one transfer request' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post(':id/fulfill')
@WagonTransferFulfill()
@ApiOperation({ summary: 'OCC: pick wagons and execute the transfer' })
fulfill(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: FulfillTransferRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.fulfillRequest(id, dto, user?.id);
}
@Post(':id/close-short')
@WagonTransferCloseShort()
@ApiOperation({
summary:
'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall',
})
closeShort(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CloseShortTransferRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.closeShort(id, dto, user?.id);
}
@Post(':id/cancel')
@WagonTransferCancel()
@ApiOperation({
summary:
'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)',
})
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.service.cancelRequest(id);
}
}