feat(warehouse): stamp authenticated user as action actor (performedBy)

Warehouse mutation endpoints now record the JWT-authenticated user as the actor
(user.id) instead of trusting a client-supplied performedBy string, unlocking
per-operator productivity metrics and a trustworthy audit trail. Covers receive,
receive-bulk, reserve, store, ready-for-loading, ready-for-pickup, load-onto-
train, bulk-dispatch, dispatch, deliver, gate-clearance, approve-delivery, the
Djibouti/import auto-unload actions, and fee-invoice generation. The prior
client value is kept only as a fallback for unauthenticated/internal calls.

move/load do not yet carry an actor (their DTOs have no performedBy) — separate
follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-15 14:51:21 +00:00
parent ac90b04be0
commit 2044312d93
2 changed files with 50 additions and 23 deletions

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -127,7 +129,8 @@ export class WarehouseInventoryController {
@Post('receive-bulk')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
receiveBulk(@Body() dto: BulkReceiveDto) {
receiveBulk(@Body() dto: BulkReceiveDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = user?.id ?? dto.performedBy;
return this.inventoryService.bulkReceive(dto);
}
@@ -173,15 +176,16 @@ export class WarehouseInventoryController {
loadItemsOntoTrain(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: { inventoryIds: string[]; performedBy?: string },
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy);
return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], user?.id ?? dto.performedBy);
}
@Post('bulk-dispatch-export')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], user?.id ?? dto.performedBy);
}
@Post('bulk-mark-inspected')
@@ -204,8 +208,12 @@ export class WarehouseInventoryController {
@Post(':id/gate-clearance')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass)
@ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' })
gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.gateClearance(id, performedBy);
gateClearance(
@Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.gateClearance(id, user?.id ?? performedBy);
}
@Get('import/arrive-queue')
@@ -230,10 +238,10 @@ export class WarehouseInventoryController {
warehouseId?: string;
performedBy?: string;
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
}) {
}, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.autoUnloadArrivedBookings(
dto.scheduleId,
dto.performedBy,
user?.id ?? dto.performedBy,
dto.warehouseId,
dto.assignments,
);
@@ -275,8 +283,8 @@ export class WarehouseInventoryController {
@Post('export/auto-unload-at-djibouti')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' })
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy);
autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, user?.id ?? dto.performedBy);
}
@Get('import/pickup-ready-queue')
@@ -303,14 +311,16 @@ export class WarehouseInventoryController {
@Post('receive')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.receive)
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
receive(@Body() dto: ReceiveWarehouseInventoryDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = user?.id ?? dto.performedBy;
return this.inventoryService.receive(dto);
}
@Post('reserve')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
reserve(@Body() dto: ReserveInventoryDto) {
reserve(@Body() dto: ReserveInventoryDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = user?.id ?? dto.performedBy;
return this.inventoryService.reserve(dto);
}
@@ -345,15 +355,19 @@ export class WarehouseInventoryController {
@Post(':id/store')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
return this.inventoryService.store(id, dto.performedBy, dto);
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) {
return this.inventoryService.store(id, user?.id ?? dto.performedBy, dto);
}
@Post(':id/ready-for-loading')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForLoading(id, performedBy);
readyForLoading(
@Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.readyForLoading(id, user?.id ?? performedBy);
}
@Post(':id/load')
@@ -366,8 +380,12 @@ export class WarehouseInventoryController {
@Post(':id/ready-for-pickup')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.move)
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForPickup(id, performedBy);
readyForPickup(
@Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.readyForPickup(id, user?.id ?? performedBy);
}
@Post(':id/release')
@@ -429,10 +447,11 @@ export class WarehouseInventoryController {
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ApproveDeliveryDto,
@Request() req: { user?: { id?: string; sub?: string } },
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.approveDeliveryForBooking(
bookingId,
req.user?.id ?? req.user?.sub,
user?.id ?? req.user?.id ?? req.user?.sub,
dto.signerName,
);
}
@@ -494,14 +513,19 @@ export class WarehouseInventoryController {
@Post(':id/deliver')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = user?.id ?? dto.performedBy;
return this.inventoryService.deliver(id, dto);
}
@Patch(':id/dispatch')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch)
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.dispatch(id, performedBy);
dispatch(
@Param('id', ParseUUIDPipe) id: string,
@Body('performedBy') performedBy: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.inventoryService.dispatch(id, user?.id ?? performedBy);
}
}

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -17,7 +19,8 @@ export class WarehouseInvoiceController {
@Post('warehouse-inventory/:id/generate-fee-invoice')
@BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate)
@ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' })
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) {
generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto, @CurrentUser() user: TCurrentUser) {
dto.performedBy = user?.id ?? dto.performedBy;
return this.invoiceService.generateForInventory(id, dto);
}