This commit is contained in:
Marshal
2026-08-07 09:04:11 +00:00
72 changed files with 3224 additions and 1124 deletions

View File

@@ -1,15 +1,13 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@edr/api-common';
import { BookingStaff } from '../../common/booking-guards';
import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
import { AiBookingResult } from './types/ai-booking-result.type';
import { MockAiService } from './mock-ai.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
// @Public() — TODO: swap for real guard when this leaves dev/testing.
// Safe while public: extracts + validates text only, never creates or
// dispatches anything.
@Public()
@BookingStaff(FREIGHT_PERMS.bookings.view)
@ApiTags('AI Assistant (mock)')
@Controller('ai')
export class AiController {

View File

@@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ListUsersService } from './list-users.service';
import { StaffReference } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@ApiTags('auth')
@Controller('staff/users')
@@ -12,7 +13,7 @@ export class ListUsersController {
constructor(private readonly service: ListUsersService) {}
@Get()
@StaffReference()
@BookingStaff(FREIGHT_PERMS.staff.users.view)
@ApiOperation({
summary: 'List IAM users (paginated) for backoffice pickers',
})

View File

@@ -10,18 +10,19 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeService } from "./backoffice.service";
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
@ApiTags("backoffice")
@Controller("backoffice")
@FreightAdmin()
export class BackofficeController {
constructor(private readonly backofficeService: BackofficeService) {}
@Post("organizations/:orgId/users")
@BookingStaff([FREIGHT_PERMS.staff.employeeRegistration.create, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Create an organization user without assigning positions" })
createOrganizationUser(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -31,6 +32,7 @@ export class BackofficeController {
}
@Get("organizations/:orgId/employees")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
getOrganizationEmployees(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -44,6 +46,7 @@ export class BackofficeController {
}
@Get("organizations/:orgId/employee-users/:userId/roles")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.view, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
getEmployeeUserRoles(
@Param("orgId", ParseUUIDPipe) organizationId: string,
@@ -53,6 +56,7 @@ export class BackofficeController {
}
@Put("organizations/:orgId/employee-users/:userId/roles")
@BookingStaff([FREIGHT_PERMS.staff.roleAssignment.replace, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace org-scoped roles assigned to an employee user" })
replaceEmployeeUserRoles(
@Param("orgId", ParseUUIDPipe) organizationId: string,

View File

@@ -12,14 +12,15 @@ 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 { BookingView } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing")
@Controller("billing")
@BookingView()
@BookingStaff(FREIGHT_PERMS.invoices.view)
@ApiBearerAuth()
export class BillingController {
constructor(
@@ -51,6 +52,7 @@ export class BillingController {
}
@Get("invoices/:id/document")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed invoice PDF" })
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.document(id);
@@ -58,6 +60,7 @@ export class BillingController {
}
@Get("invoices/:id/receipt")
@BookingStaff(FREIGHT_PERMS.invoices.export)
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.billingService.receipt(id);

View File

@@ -12,6 +12,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { CurrentUser } from "@edr/api-common";
import { PortalCustomer } from "../../common/booking-guards";
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -28,6 +29,7 @@ import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
@ApiTags("billing")
@ApiBearerAuth()
@Controller("billing")
@PortalCustomer()
export class PortalBillingController {
constructor(private readonly billingService: BillingService) {}

View File

@@ -279,7 +279,9 @@ export class BookingPricingService {
return {
lineItems,
totalAmount: total,
// Grand total is billed in whole currency units — fractional line sums
// (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
totalAmount: Math.round(total),
currency: booking.paymentCurrency,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,

View File

@@ -15,15 +15,15 @@ import {
UnauthorizedException,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import {
BookingStaff,
BookingView,
MixedAudience,
PortalCustomer,
WagonCancellationView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -167,6 +167,7 @@ export class BookingsController {
) {}
@Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@@ -207,6 +208,7 @@ export class BookingsController {
}
@Patch(":id")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -223,6 +225,7 @@ export class BookingsController {
}
@Get()
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll(
@Query() filter: FilterBookingDto,
@@ -298,6 +301,7 @@ export class BookingsController {
}
@Get("my")
@PortalCustomer()
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
@@ -328,6 +332,7 @@ export class BookingsController {
}
@Get("reference-data")
@MixedAudience([])
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
@@ -335,6 +340,7 @@ export class BookingsController {
}
@Get("by-reference/:reference")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get booking by reference" })
async findByReference(
@Param("reference") reference: string,
@@ -352,6 +358,7 @@ export class BookingsController {
}
@Get(":id")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get booking by ID" })
async findOne(
@Param("id", ParseUUIDPipe) id: string,
@@ -373,6 +380,7 @@ export class BookingsController {
}
@Get(':id/available-days')
@MixedAudience([])
@ApiOperation({
summary:
'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)',
@@ -395,6 +403,7 @@ export class BookingsController {
}
@Get(':id/day-availability')
@MixedAudience([])
@ApiOperation({
summary:
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
@@ -420,6 +429,7 @@ export class BookingsController {
}
@Get(':id/mile-summary')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
})
@@ -447,6 +457,7 @@ export class BookingsController {
}
@Post(':id/customer-truck-assignment')
@PortalCustomer()
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -462,6 +473,7 @@ export class BookingsController {
}
@Get(':id/customer-truck-assignment/freight-order')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
@@ -488,6 +500,7 @@ export class BookingsController {
}
@Get(':id/carriage-acceptance-sheet')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
@@ -652,6 +665,11 @@ export class BookingsController {
}
@Get(':id/customer-trucks')
@MixedAudience([
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.warehouseInventory.view,
])
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string,
@@ -665,6 +683,7 @@ export class BookingsController {
}
@Post(':id/customer-trucks')
@PortalCustomer()
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 12 of the booking containers' })
async addCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -679,6 +698,7 @@ export class BookingsController {
}
@Post(':id/customer-trucks/bulk')
@PortalCustomer()
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
async bulkAddCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string,
@@ -693,6 +713,7 @@ export class BookingsController {
}
@Patch(':id/customer-trucks/:assignmentId')
@PortalCustomer()
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
async updateCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -708,6 +729,7 @@ export class BookingsController {
}
@Delete(':id/customer-trucks/:assignmentId')
@PortalCustomer()
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -722,6 +744,7 @@ export class BookingsController {
}
@Get(':id/customer-trucks/loadable-containers')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
async loadableContainers(
@Param('id', ParseUUIDPipe) id: string,
@@ -735,6 +758,7 @@ export class BookingsController {
}
@Post(':id/customer-trucks/:assignmentId/load')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
async loadCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@@ -749,6 +773,7 @@ export class BookingsController {
}
@Post(':id/customer-trucks/:assignmentId/depart')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
})
@@ -766,6 +791,7 @@ export class BookingsController {
}
@Get(':id/received-pending-grn')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
async receivedPendingGrn(
@Param('id', ParseUUIDPipe) id: string,
@@ -779,6 +805,7 @@ export class BookingsController {
}
@Post(':id/generate-grn')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
@@ -796,6 +823,7 @@ export class BookingsController {
}
@Get(':id/tracking')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary: "Shipment tracking timeline for a booking",
description:
@@ -818,6 +846,7 @@ export class BookingsController {
}
@Delete(":id")
@MixedAudience([])
@HttpCode(204)
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -825,6 +854,7 @@ export class BookingsController {
}
@Post(":id/documents")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
@@ -837,6 +867,7 @@ export class BookingsController {
}
@Post(":id/generate-price")
@MixedAudience([])
@ApiOperation({
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description:
@@ -848,6 +879,7 @@ export class BookingsController {
}
@Post(":id/submit")
@MixedAudience([])
@ApiOperation({
summary: "Customer submit booking",
description:
@@ -859,6 +891,7 @@ export class BookingsController {
}
@Post(":id/confirm-submit")
@MixedAudience([])
@ApiOperation({
summary: "Confirm submit after price change",
description:
@@ -870,6 +903,7 @@ export class BookingsController {
}
@Post(":id/reject")
@PortalCustomer()
@ApiOperation({
summary: "Customer reject price estimate",
description:
@@ -900,6 +934,7 @@ export class BookingsController {
}
@Get(':id/clearance')
@MixedAudience([FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments])
@ApiOperation({
summary:
"Document-clearance grid (required docs + upload + GL review status)",
@@ -909,6 +944,7 @@ export class BookingsController {
}
@Post(":id/clearance/documents")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -925,7 +961,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Customer requests the operation; GL ET also resubmits here on the
// customer's behalf after operations requests changes (BookingChangesRequestedAlert).
@Post(":id/clearance/proceed")
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
"Customer requests operation with a schedule day " +
@@ -944,6 +983,7 @@ export class BookingsController {
}
@Get(":id/export-trains")
@MixedAudience([])
@ApiOperation({
summary:
"Export train picker: the day's export trains on the booking's corridor " +
@@ -1145,6 +1185,7 @@ export class BookingsController {
}
@Post(':id/clearance/draft-declaration/accept')
@PortalCustomer()
@ApiOperation({
summary:
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
@@ -1155,6 +1196,7 @@ export class BookingsController {
}
@Post(':id/clearance/draft-declaration/change')
@PortalCustomer()
@ApiOperation({
summary:
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
@@ -1181,6 +1223,7 @@ export class BookingsController {
}
@Post(':id/clearance/duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
@@ -1332,7 +1375,7 @@ export class BookingsController {
}
@Post(":id/government-expedite")
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@BookingStaff(FREIGHT_PERMS.bookings.governmentExpedite)
@ApiOperation({
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
})
@@ -1356,6 +1399,7 @@ export class BookingsController {
}
@Get(":id/contract/view")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
getContractView(
@@ -1367,6 +1411,7 @@ export class BookingsController {
}
@Get(":id/contract/document")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Download contract PDF" })
async downloadContractDocument(
@Param("id", ParseUUIDPipe) id: string,
@@ -1382,6 +1427,7 @@ export class BookingsController {
}
@Get(":id/contract")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Download contract file (alias)" })
async downloadContract(
@Param("id", ParseUUIDPipe) id: string,
@@ -1391,7 +1437,7 @@ export class BookingsController {
}
@Post(":id/contract/sign")
@UseGuards(JwtGuard)
@MixedAudience(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
async signContract(
@Param("id", ParseUUIDPipe) id: string,
@@ -1412,18 +1458,21 @@ export class BookingsController {
}
@Get(":id/contract/signatures")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List contract signatures" })
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(":id/summary")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Contract summary string for dashboard" })
getSummary(@Param("id", ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(":id/customer/sign")
@PortalCustomer()
@ApiOperation({
summary: "Customer digital signature (deprecated — use POST contract/sign)",
})
@@ -1504,6 +1553,7 @@ export class BookingsController {
}
@Post(":id/cancel-hold")
@PortalCustomer()
@ApiOperation({
summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
@@ -1518,18 +1568,21 @@ export class BookingsController {
}
@Post(":id/consolidation")
@PortalCustomer()
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
@Delete(":id/consolidation")
@PortalCustomer()
@ApiOperation({ summary: "Remove consolidation pairing" })
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
@Get(":id/consolidation")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "Get consolidation details" })
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);

View File

@@ -25,6 +25,7 @@ import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.se
import { BookingTransitionService } from './booking-transition.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingAllocationController } from './booking-allocation.controller';
import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
@@ -92,7 +93,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
SignaturesModule,
registerExchangeModule(),
],
controllers: [BookingsController],
controllers: [BookingsController, BookingAllocationController],
providers: [
BookingsService,
BookingsRepository,

View File

@@ -11,7 +11,6 @@ import {
HttpCode,
HttpStatus,
UseInterceptors,
UseGuards,
UploadedFiles,
BadRequestException,
NotFoundException,
@@ -20,8 +19,7 @@ import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { BookingStaff } from "../../common/booking-guards";
import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards";
import {
assertFreightPermission,
hasFreightPermission,
@@ -118,6 +116,7 @@ export class CompaniesController {
}
@Get("getInfo")
@PortalCustomer()
@ApiOperation({ summary: "Get company info for the current user" })
async getInfo(
@CurrentUser() user: CurrentIamUser,
@@ -131,6 +130,7 @@ export class CompaniesController {
}
@Get("profile")
@PortalCustomer()
@ApiOperation({ summary: "Get flattened profile for the settings page" })
async getProfile(
@CurrentUser() user: CurrentIamUser,
@@ -146,6 +146,7 @@ export class CompaniesController {
}
@Get("profile/change-request")
@PortalCustomer()
@ApiOperation({
summary: "Current user's open profile change request (pending/rejected)",
})
@@ -161,6 +162,7 @@ export class CompaniesController {
}
@Post("company-profiles/:profileId/reapply")
@PortalCustomer()
@ApiOperation({
summary: "Resubmit a rejected operational role for approval (→ pending)",
})
@@ -176,6 +178,7 @@ export class CompaniesController {
}
@Get("dashboard")
@PortalCustomer()
@ApiOperation({
summary:
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
@@ -191,6 +194,7 @@ export class CompaniesController {
}
@Post("fetch-etrade-info")
@PortalCustomer()
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
@CurrentUser() user: CurrentIamUser,
@@ -211,6 +215,7 @@ export class CompaniesController {
}
@Patch("profile")
@PortalCustomer()
@ApiOperation({ summary: "Update profile (flattened settings page)" })
async updateProfile(
@CurrentUser() user: CurrentIamUser,
@@ -220,6 +225,7 @@ export class CompaniesController {
}
@Post("company-profiles")
@PortalCustomer()
@ApiOperation({
summary:
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
@@ -236,6 +242,7 @@ export class CompaniesController {
}
@Post("onboarding/start")
@PortalCustomer()
@ApiOperation({
summary:
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
@@ -261,6 +268,7 @@ export class CompaniesController {
}
@Post("company-profile")
@PortalCustomer()
@ApiOperation({
summary:
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
@@ -278,6 +286,7 @@ export class CompaniesController {
}
@Post("company-profiles/:profileId/license")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -298,6 +307,7 @@ export class CompaniesController {
}
@Post("company-profiles/:profileId/license/:fileId/replace")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -324,6 +334,7 @@ export class CompaniesController {
}
@Delete("company-profiles/:profileId/license/:fileId")
@PortalCustomer()
@ApiOperation({
summary:
"Remove a business-license file (staged for review on an approved company).",
@@ -341,6 +352,7 @@ export class CompaniesController {
}
@Get("company-profiles/:profileId/license")
@PortalCustomer()
@ApiOperation({
summary: "List business-license documents (with review state) for a profile",
})
@@ -352,6 +364,7 @@ export class CompaniesController {
}
@Get("poa-delegation")
@PortalCustomer()
@ApiOperation({
summary:
"List the Power of Attorney delegation letter (with review state) for the current user's company",
@@ -363,6 +376,7 @@ export class CompaniesController {
}
@Post("poa-delegation")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -383,6 +397,7 @@ export class CompaniesController {
}
@Delete("poa-delegation/:fileId")
@PortalCustomer()
@ApiOperation({
summary:
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
@@ -395,6 +410,7 @@ export class CompaniesController {
}
@Post("identity/fayda/complete")
@PortalCustomer()
@ApiOperation({
summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
@@ -409,6 +425,7 @@ export class CompaniesController {
}
@Post("identity/gm/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the General Manager is the company's owner, copying the owner's verified identity across. " +
@@ -421,6 +438,7 @@ export class CompaniesController {
}
@Delete("identity/gm")
@PortalCustomer()
@ApiOperation({
summary:
"Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote. " +
@@ -433,6 +451,7 @@ export class CompaniesController {
}
@Delete("identity/fayda/poa")
@PortalCustomer()
@ApiOperation({
summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
@@ -445,6 +464,7 @@ export class CompaniesController {
}
@Patch("onboarding-step")
@PortalCustomer()
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)
async setOnboardingStep(
@@ -455,6 +475,7 @@ export class CompaniesController {
}
@Get("onboarding/requirements")
@PortalCustomer()
@ApiOperation({
summary:
"What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)",
@@ -466,6 +487,7 @@ export class CompaniesController {
}
@Post("onboarding/complete")
@PortalCustomer()
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
async completeOnboarding(
@CurrentUser() user: CurrentIamUser,
@@ -477,6 +499,7 @@ export class CompaniesController {
// Used by portal
@Post("create")
@PortalCustomer()
@ApiOperation({
summary:
"Create a company with its associated external profile (onboarding)",
@@ -591,7 +614,11 @@ export class CompaniesController {
* permission still needs the applicant's documents.
*/
@Get(":companyId/documents")
@UseGuards(JwtGuard)
@MixedAudience([
FREIGHT_PERMS.customers.view,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.bookings.view,
])
@ApiOperation({ summary: "List documents uploaded for a company" })
async listDocuments(
@Param("companyId", ParseUUIDPipe) companyId: string,
@@ -661,6 +688,7 @@ export class CompaniesController {
}
@Post(":companyId/documents")
@MixedAudience(FREIGHT_PERMS.customers.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })

View File

@@ -1,5 +1,7 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ComplianceService } from './compliance.service';
import {
CreateComplianceRecordDto,
@@ -9,10 +11,12 @@ import { ComplianceType } from './entities/compliance-record.entity';
@ApiTags('Vehicle Compliance')
@Controller('compliance')
@BookingStaff(FREIGHT_PERMS.compliance.view)
export class ComplianceController {
constructor(private readonly complianceService: ComplianceService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Create a compliance record' })
create(@Body() dto: CreateComplianceRecordDto) {
return this.complianceService.create(dto);
@@ -40,12 +44,14 @@ export class ComplianceController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Update a compliance record' })
update(@Param('id') id: string, @Body() dto: UpdateComplianceRecordDto) {
return this.complianceService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.compliance.manage)
@ApiOperation({ summary: 'Soft-delete a compliance record' })
remove(@Param('id') id: string) {
return this.complianceService.remove(id);

View File

@@ -10,7 +10,8 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { ContractTemplatesService } from "./contract-templates.service";
import {
CreateArticleDto,
@@ -25,29 +26,44 @@ import {
export class ContractTemplatesController {
constructor(private readonly service: ContractTemplatesService) {}
// Reads stay open to authenticated staff (the backoffice Templates tab);
// Reads are staff-only (the backoffice Templates tab is the only consumer);
// writes are admin-guarded like other freight configuration resources.
@Get()
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({ summary: "List the six contract document templates" })
list() {
return this.service.list();
}
@Get(":code")
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({ summary: "Get one contract template by code" })
getByCode(@Param("code") code: string) {
return this.service.getByCode(code);
}
@Patch(":code")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" })
update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) {
return this.service.update(code, dto);
}
@Post(":code/preview")
@BookingStaff([
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.settings.contractTemplates.manage,
FREIGHT_PERMS.admin,
])
@ApiOperation({
summary: "Render an HTML preview of the template against mock contract data",
})
@@ -61,21 +77,21 @@ export class ContractTemplatesController {
/* ------------------------- article routes ------------------------- */
@Put(":code/articles")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" })
replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) {
return this.service.replaceArticles(code, dto.articles);
}
@Post(":code/articles")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Add an article to the template" })
addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) {
return this.service.addArticle(code, dto);
}
@Patch(":code/articles/:articleId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update an article's title or body" })
updateArticle(
@Param("code") code: string,
@@ -86,7 +102,7 @@ export class ContractTemplatesController {
}
@Delete(":code/articles/:articleId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.contractTemplates.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Remove an article from the template" })
removeArticle(
@Param("code") code: string,

View File

@@ -14,12 +14,10 @@ import {
UnauthorizedException,
UploadedFiles,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import {
@@ -32,7 +30,7 @@ import {
} from '@nestjs/swagger';
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import {
FREIGHT_PERMS,
@@ -127,6 +125,7 @@ export class ContractsController {
}
@Get('booking-requests/:reqId')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'A single shipment request' })
getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) {
return this.bookingRequestService.findOne(reqId);
@@ -159,6 +158,7 @@ export class ContractsController {
}
@Post('booking-requests/:reqId/cancel')
@PortalCustomer()
@ApiOperation({ summary: 'Customer cancels their own pending shipment request' })
cancelBookingRequest(
@Param('reqId', ParseUUIDPipe) reqId: string,
@@ -168,6 +168,7 @@ export class ContractsController {
}
@Post(':id/booking-requests')
@PortalCustomer()
@ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' })
submitBookingRequest(
@Param('id', ParseUUIDPipe) id: string,
@@ -178,12 +179,14 @@ export class ContractsController {
}
@Get(':id/booking-requests')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List the shipment requests on a contract' })
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingRequestService.listForContract(id);
}
@Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' })
@@ -203,6 +206,7 @@ export class ContractsController {
}
@Get()
@MixedAudience([])
@ApiOperation({ summary: 'List contracts (paginated)' })
async findAll(
@Query() filter: FilterContractDto,
@@ -247,6 +251,7 @@ export class ContractsController {
}
@Get('my')
@PortalCustomer()
@ApiOperation({ summary: "List the current customer's contracts" })
async findMy(
@CurrentUser() user: AuthUserPayload,
@@ -301,6 +306,7 @@ export class ContractsController {
}
@Get(':id')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@@ -319,6 +325,7 @@ export class ContractsController {
}
@Patch(':id')
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
@@ -337,6 +344,7 @@ export class ContractsController {
}
@Delete(':id')
@MixedAudience([])
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT contract' })
remove(@Param('id', ParseUUIDPipe) id: string) {
@@ -344,6 +352,7 @@ export class ContractsController {
}
@Post(':id/documents')
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' })
@@ -355,18 +364,21 @@ export class ContractsController {
}
@Post(':id/generate-price')
@MixedAudience([])
@ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@MixedAudience([])
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@MixedAudience([])
@ApiOperation({ summary: 'Confirm submit after a price change' })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
@@ -427,10 +439,7 @@ export class ContractsController {
// real boundary: it admits only the approver whose step is currently pending
// (edit rights hand off down the chain on each approval).
@Put(':id/document/articles')
@BookingStaff([
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
])
@BookingStaff(FREIGHT_PERMS.contracts.editDocument)
@ApiOperation({
summary:
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
@@ -515,6 +524,7 @@ export class ContractsController {
}
@Post(':id/cancel')
@PortalCustomer()
@ApiOperation({
summary: 'Customer cancels their own contract (blocked while a booking is live)',
})
@@ -591,6 +601,7 @@ export class ContractsController {
}
@Get(':id/contract/view')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' })
async getContractView(
@Param('id', ParseUUIDPipe) id: string,
@@ -630,6 +641,7 @@ export class ContractsController {
}
@Get(':id/contract/document')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@@ -653,7 +665,7 @@ export class ContractsController {
}
@Post(':id/contract/send-signing-otp')
@UseGuards(JwtGuard)
@MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
@ApiOperation({
summary:
"Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)",
@@ -666,7 +678,7 @@ export class ContractsController {
}
@Post(':id/contract/sign')
@UseGuards(JwtGuard)
@MixedAudience(bothFreightTypes(FREIGHT_PERMS.contracts.signStaff))
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@@ -691,6 +703,7 @@ export class ContractsController {
}
@Post(':id/renew')
@PortalCustomer()
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
async renew(
@Param('id', ParseUUIDPipe) id: string,
@@ -712,12 +725,14 @@ export class ContractsController {
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
@Get(':id/clearance')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' })
getClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.getClearanceView(id);
}
@Post(':id/clearance/documents')
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
@@ -901,6 +916,7 @@ export class ContractsController {
}
@Post(':id/clearance/duty/dispute')
@PortalCustomer()
@ApiOperation({
summary:
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
@@ -914,6 +930,7 @@ export class ContractsController {
}
@Post(':id/clearance/duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' })
@@ -1057,6 +1074,7 @@ export class ContractsController {
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
@@ -1078,6 +1096,7 @@ export class ContractsController {
}
@Post(':id/bookings/initiate')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
@@ -1096,6 +1115,7 @@ export class ContractsController {
}
@Post(':id/bookings/:bookingId/complete')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing.',
@@ -1117,6 +1137,7 @@ export class ContractsController {
}
@Post(':id/validate-shipment')
@MixedAudience([FREIGHT_PERMS.contracts.createBooking, FREIGHT_PERMS.contracts.view])
@ApiOperation({
summary:
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
@@ -1132,6 +1153,7 @@ export class ContractsController {
}
@Get(':id/capacity')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({
summary:
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
@@ -1144,12 +1166,14 @@ export class ContractsController {
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
@Get(':id/milestones')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' })
listContractMilestones(@Param('id', ParseUUIDPipe) id: string) {
return this.milestoneService.listForContract(id);
}
@Get('bookings/:bookingId/milestones')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' })
listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.milestoneService.listForBooking(bookingId);
@@ -1283,7 +1307,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/final-invoice')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceRaise)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
@@ -1310,6 +1334,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/final-invoice/approve')
@PortalCustomer()
@ApiOperation({
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
})
@@ -1324,6 +1349,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/final-invoice-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' })
@@ -1335,10 +1361,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/final-invoice/confirm')
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceDjActions,
FREIGHT_PERMS.contracts.clearanceEtActions,
])
@BookingStaff(FREIGHT_PERMS.contracts.finalInvoiceConfirm)
@ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' })
confirmFinalInvoicePaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -1381,6 +1404,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/second-duty-slip')
@PortalCustomer()
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
@@ -1406,6 +1430,7 @@ export class ContractsController {
}
@Post('bookings/:bookingId/duty-slip')
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
@@ -1422,6 +1447,7 @@ export class ContractsController {
}
@Get('bookings/:bookingId/incidents')
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.glOperationsService.listIncidents(bookingId);

View File

@@ -1,22 +0,0 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard";
@ApiTags("demo-permissions")
@Controller()
export class DemoPermissionsController {
@Get("test_user1")
@ApiOperation({ summary: "Permission demo (can:demo:user1)" })
@UseGuards(PermissionGuard(["can:demo:user1"]))
testUser1() {
return { ok: true, permission: "can:demo:user1" };
}
@Get("test_user2")
@ApiOperation({ summary: "Permission demo (can:demo:user2)" })
@UseGuards(PermissionGuard(["can:demo:user2"]))
testUser2() {
return { ok: true, permission: "can:demo:user2" };
}
}

View File

@@ -1,8 +0,0 @@
import { Module } from "@nestjs/common";
import { DemoPermissionsController } from "./demo-permissions.controller";
@Module({
controllers: [DemoPermissionsController],
})
export class DemoPermissionsModule {}

View File

@@ -14,7 +14,8 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
@@ -58,14 +59,14 @@ export class DropdownSettingsController {
}
@Post()
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Create a new dropdown setting" })
create(@Body() dto: CreateDropdownSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a dropdown setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@@ -75,7 +76,7 @@ export class DropdownSettingsController {
}
@Delete(":id")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a dropdown setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -85,7 +86,7 @@ export class DropdownSettingsController {
/* ------------------------- option routes ------------------------- */
@Put(":id/options")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the full option list for a setting" })
replaceOptions(
@Param("id", ParseUUIDPipe) id: string,
@@ -95,7 +96,7 @@ export class DropdownSettingsController {
}
@Post(":id/options")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Append a single option to a setting" })
addOption(
@Param("id", ParseUUIDPipe) id: string,
@@ -105,7 +106,7 @@ export class DropdownSettingsController {
}
@Patch("options/:optionId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a single option" })
updateOption(
@Param("optionId", ParseUUIDPipe) optionId: string,
@@ -115,7 +116,7 @@ export class DropdownSettingsController {
}
@Delete("options/:optionId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.dropdown.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a single option" })
@HttpCode(HttpStatus.NO_CONTENT)
removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) {

View File

@@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOperation, 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 { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
import { ExchangeSettingsService } from "./exchange-settings.service";
@@ -14,7 +15,7 @@ export class ExchangeSettingsController {
constructor(private readonly service: ExchangeSettingsService) {}
@Get()
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
@ApiOperation({
summary: "Current USD→ETB fallback rate and CBE feed health",
})
@@ -32,7 +33,7 @@ export class ExchangeSettingsController {
}
@Patch()
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin])
@ApiOperation({
summary:
"Set the USD→ETB fallback by hand (used only while CBE is unreachable)",

View File

@@ -1,6 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateFacilityDto } from './dto/create-facility.dto';
import { UpdateFacilityDto } from './dto/update-facility.dto';
import { Facility } from './entities/facility.entity';
@@ -8,10 +10,12 @@ import { FacilitiesService } from './facilities.service';
@ApiTags('Facilities')
@Controller('facilities')
@BookingStaff(FREIGHT_PERMS.facilities.view)
export class FacilitiesController {
constructor(private readonly facilitiesService: FacilitiesService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.facilities.manage)
@ApiOperation({ summary: 'Create a new facility' })
async create(@Body() createFacilityDto: CreateFacilityDto): Promise<Facility> {
return this.facilitiesService.create(createFacilityDto);
@@ -30,6 +34,7 @@ export class FacilitiesController {
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.facilities.manage)
@ApiOperation({ summary: 'Update a facility' })
async update(@Param('id') id: string, @Body() updateFacilityDto: UpdateFacilityDto): Promise<Facility | null> {
return this.facilitiesService.update(id, updateFacilityDto);
@@ -37,6 +42,7 @@ export class FacilitiesController {
@Delete(':id')
@HttpCode(204)
@BookingStaff(FREIGHT_PERMS.facilities.manage)
@ApiOperation({ summary: 'Delete a facility (soft delete)' })
async remove(@Param('id') id: string): Promise<void> {
return this.facilitiesService.remove(id);

View File

@@ -13,7 +13,8 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
@@ -53,14 +54,14 @@ export class FileUploadSettingsController {
}
@Post()
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Create a new file upload setting" })
create(@Body() dto: CreateFileUploadSettingDto) {
return this.service.create(dto);
}
@Patch(":id")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a file upload setting's metadata" })
update(
@Param("id", ParseUUIDPipe) id: string,
@@ -70,7 +71,7 @@ export class FileUploadSettingsController {
}
@Delete(":id")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a file upload setting" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -80,7 +81,7 @@ export class FileUploadSettingsController {
/* ------------------------- field routes ------------------------- */
@Put(":id/fields")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Replace the full field list for a setting" })
replaceFields(
@Param("id", ParseUUIDPipe) id: string,
@@ -90,7 +91,7 @@ export class FileUploadSettingsController {
}
@Post(":id/fields")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Append a single field to a setting" })
addField(
@Param("id", ParseUUIDPipe) id: string,
@@ -100,7 +101,7 @@ export class FileUploadSettingsController {
}
@Patch("fields/:fieldId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Update a single field" })
updateField(
@Param("fieldId", ParseUUIDPipe) fieldId: string,
@@ -110,7 +111,7 @@ export class FileUploadSettingsController {
}
@Delete("fields/:fieldId")
@FreightAdmin()
@BookingStaff([FREIGHT_PERMS.settings.fileUpload.manage, FREIGHT_PERMS.admin])
@ApiOperation({ summary: "Soft-delete a single field" })
@HttpCode(HttpStatus.NO_CONTENT)
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {

View File

@@ -16,6 +16,7 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { MixedAudience } from "../../common/booking-guards";
import { FilesService } from "./files.service";
@ApiTags("files")
@@ -25,6 +26,7 @@ export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get(":fileId")
@MixedAudience([])
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer

View File

@@ -28,7 +28,9 @@ import { FirstMileInvoiceService } from './first-mile-invoice.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@BookingStaff(FREIGHT_PERMS.firstMile.view)
// 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 FirstMileController {
constructor(
private readonly firstMileService: FirstMileService,
@@ -36,6 +38,7 @@ export class FirstMileController {
) { }
@Get()
@BookingStaff(FREIGHT_PERMS.firstMile.view)
@ApiOperation({ summary: 'List first-mile legs' })
findAll(
@Query('status') status?: string,
@@ -58,6 +61,7 @@ export class FirstMileController {
}
@Get(':id')
@BookingStaff(FREIGHT_PERMS.firstMile.view)
@ApiOperation({ summary: 'Get a first-mile leg by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.findById(id);

View File

@@ -6,7 +6,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { LastMileRequestStatus } from '@edr/types';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto';
@@ -61,12 +61,14 @@ export class LastMileRequestsController {
// Customer-facing like :id/submit — the service ownership-checks against the
// resolved company; staff may also open it (read-only view).
@Get(':id/contract/view')
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' })
contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
return this.contractService.getContractView(id, user?.id ?? null);
}
@Get(':id/contract/document')
@MixedAudience(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'Download the LM contract PDF (LM_<CustomerName>.pdf)' })
async contractDocument(
@Param('id', ParseUUIDPipe) id: string,
@@ -79,6 +81,7 @@ export class LastMileRequestsController {
}
@Post(':id/contract/sign')
@PortalCustomer()
@ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' })
signContract(
@Param('id', ParseUUIDPipe) id: string,
@@ -99,6 +102,7 @@ export class LastMileRequestsController {
// TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service
// still cross-checks the request's booking against the resolved company.
@Post(':id/submit')
@PortalCustomer()
@ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" })
submit(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -34,7 +34,9 @@ import { LastMileInvoiceService } from './last-mile-invoice.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@BookingStaff(FREIGHT_PERMS.lastMile.view)
// 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,
@@ -42,6 +44,7 @@ export class LastMileController {
) {}
@Get()
@BookingStaff(FREIGHT_PERMS.lastMile.view)
@ApiOperation({ summary: 'List last-mile legs' })
findAll(
@Query('status') status?: string,
@@ -64,18 +67,21 @@ export class LastMileController {
}
@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);

View File

@@ -1,11 +1,5 @@
import { CurrentUser } from "@edr/api-common";
import {
NotificationAudience,
NotificationPriority,
NotificationType,
} from "@edr/types";
import {
Body,
Controller,
Get,
Param,
@@ -62,22 +56,4 @@ export class NotificationInboxController {
return this.service.markAllRead(resolveAuthUserId(user));
}
// TODO: remove before merge — dev/verification helper only.
@Post("test")
@ApiOperation({
summary: "[dev] Send a test notification to the current user",
})
sendTest(
@CurrentUser() user: AuthUserPayload,
@Body()
body: {
audience?: NotificationAudience;
type?: NotificationType;
priority?: NotificationPriority;
title?: string;
message?: string;
},
) {
return this.service.sendTestToUser(resolveAuthUserId(user), body ?? {});
}
}

View File

@@ -1,11 +1,9 @@
import {
NotificationAudience,
NotificationChannels,
NotificationChannelsSent,
NotificationDto,
NotificationListResult,
NotificationPriority,
NotificationType,
NotifyInput,
} from "@edr/types";
import { Injectable, Logger } from "@nestjs/common";
@@ -106,31 +104,6 @@ export class NotificationInboxService {
return { updated, unreadCount };
}
/** [dev/verification only] Send a canned notification straight to one user. */
async sendTestToUser(
userId: string,
body: {
audience?: NotificationAudience;
type?: NotificationType;
priority?: NotificationPriority;
title?: string;
message?: string;
},
): Promise<NotificationDto> {
const entity = await this.repo.create({
recipientUserId: userId,
audience: body.audience ?? NotificationAudience.BACKOFFICE,
type: body.type ?? NotificationType.GENERIC,
title: body.title ?? "Test notification",
body: body.message ?? "This is a test in-app notification.",
priority: body.priority ?? NotificationPriority.NORMAL,
isRead: false,
});
const dto = this.toDto(entity);
this.gateway.emitNew(userId, dto, await this.repo.countUnread(userId));
return dto;
}
private async deliverToUser(
userId: string,
input: NotifyInput,

View File

@@ -8,7 +8,8 @@ import {
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { OverviewQueryDto } from './dto/overview-query.dto';
import { OverviewResponseDto } from './dto/overview-response.dto';
import {
@@ -32,7 +33,7 @@ export class OverviewController {
) {}
@Get()
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' })
@ApiOkResponse({ type: OverviewResponseDto })
async getDashboard(
@@ -48,7 +49,7 @@ export class OverviewController {
}
@Get('bookings')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Bookings tab metrics and charts' })
@ApiOkResponse({ type: OverviewBookingsTabDto })
async getBookingsTab(
@@ -64,7 +65,7 @@ export class OverviewController {
}
@Get('contracts')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Contracts tab metrics and charts' })
@ApiOkResponse({ type: OverviewContractsTabDto })
async getContractsTab(
@@ -80,7 +81,7 @@ export class OverviewController {
}
@Get('billing')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Billing tab metrics and charts' })
@ApiOkResponse({ type: OverviewBillingTabDto })
async getBillingTab(
@@ -96,7 +97,7 @@ export class OverviewController {
}
@Get('operations')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Operations tab metrics and charts' })
@ApiOkResponse({ type: OverviewOperationsTabDto })
getOperationsTab(
@@ -106,7 +107,7 @@ export class OverviewController {
}
@Get('customers')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Customers tab metrics and charts' })
@ApiOkResponse({ type: OverviewCustomersTabDto })
async getCustomersTab(
@@ -122,7 +123,7 @@ export class OverviewController {
}
@Get('staff')
@BookingView()
@BookingStaff(FREIGHT_PERMS.overview.view)
@ApiOperation({ summary: 'Staff tab metrics and charts' })
@ApiOkResponse({ type: OverviewStaffTabDto })
getStaffTab(@Query() query: OverviewQueryDto): Promise<OverviewStaffTabDto> {

View File

@@ -18,7 +18,7 @@ import {
import { Response } from "express";
import { CurrentUser, Public } from "@edr/api-common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { BookingStaff, BookingView } from "../../common/booking-guards";
import { BookingStaff, MixedAudience, PortalCustomer } from "../../common/booking-guards";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { PaymentService } from "./payment.service";
@@ -43,14 +43,14 @@ export class PaymentController {
}
@Get("summary")
@BookingView()
@BookingStaff(FREIGHT_PERMS.payments.view)
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })
getSummary() {
return this.paymentService.getSummary();
}
@Get("all")
@BookingView()
@BookingStaff(FREIGHT_PERMS.payments.view)
@ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" })
@ApiQuery({ name: "search", required: false })
@ApiQuery({ name: "status", required: false })
@@ -79,6 +79,7 @@ export class PaymentController {
}
@Get("intents/:bookingId")
@MixedAudience(FREIGHT_PERMS.payments.view)
@ApiOperation({ summary: "Get payment intent status for a booking" })
@ApiOkResponse({ type: IntentStatusDto })
getIntent(@Param("bookingId") bookingId: string) {
@@ -86,6 +87,7 @@ export class PaymentController {
}
@Post("redirect-success/:bookingId")
@PortalCustomer()
@ApiOperation({
summary:
"Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)",

View File

@@ -1,5 +1,7 @@
import { Controller, Post, Get, Patch, Delete, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ProcurementService } from './procurement.service';
import {
CreateVendorDto,
@@ -11,11 +13,13 @@ import {
@ApiTags('Procurement & Asset Lifecycle')
@Controller('procurement')
@BookingStaff(FREIGHT_PERMS.procurement.view)
export class ProcurementController {
constructor(private readonly procurementService: ProcurementService) {}
// ---- Vendors ----
@Post('vendors')
@BookingStaff(FREIGHT_PERMS.procurement.vendorManage)
@ApiOperation({ summary: 'Create a vendor' })
async createVendor(@Body() dto: CreateVendorDto) {
return this.procurementService.createVendor(dto);
@@ -28,12 +32,14 @@ export class ProcurementController {
}
@Patch('vendors/:id')
@BookingStaff(FREIGHT_PERMS.procurement.vendorManage)
@ApiOperation({ summary: 'Update a vendor' })
async updateVendor(@Param('id') id: string, @Body() dto: UpdateVendorDto) {
return this.procurementService.updateVendor(id, dto);
}
@Delete('vendors/:id')
@BookingStaff(FREIGHT_PERMS.procurement.vendorManage)
@ApiOperation({ summary: 'Delete a vendor' })
async deleteVendor(@Param('id') id: string) {
return this.procurementService.deleteVendor(id);
@@ -41,6 +47,7 @@ export class ProcurementController {
// ---- Acquisitions ----
@Post('acquisitions')
@BookingStaff(FREIGHT_PERMS.procurement.acquisitionManage)
@ApiOperation({ summary: 'Create an asset acquisition' })
async createAcquisition(@Body() dto: CreateAcquisitionDto) {
return this.procurementService.createAcquisition(dto);
@@ -59,12 +66,14 @@ export class ProcurementController {
}
@Patch('acquisitions/:id')
@BookingStaff(FREIGHT_PERMS.procurement.acquisitionManage)
@ApiOperation({ summary: 'Update an asset acquisition' })
async updateAcquisition(@Param('id') id: string, @Body() dto: UpdateAcquisitionDto) {
return this.procurementService.updateAcquisition(id, dto);
}
@Delete('acquisitions/:id')
@BookingStaff(FREIGHT_PERMS.procurement.acquisitionManage)
@ApiOperation({ summary: 'Delete an asset acquisition' })
async deleteAcquisition(@Param('id') id: string) {
return this.procurementService.deleteAcquisition(id);
@@ -72,6 +81,7 @@ export class ProcurementController {
// ---- Disposals ----
@Post('disposals')
@BookingStaff(FREIGHT_PERMS.procurement.disposalManage)
@ApiOperation({ summary: 'Create an asset disposal' })
async createDisposal(@Body() dto: CreateDisposalDto) {
return this.procurementService.createDisposal(dto);
@@ -84,6 +94,7 @@ export class ProcurementController {
}
@Delete('disposals/:id')
@BookingStaff(FREIGHT_PERMS.procurement.disposalManage)
@ApiOperation({ summary: 'Delete an asset disposal' })
async deleteDisposal(@Param('id') id: string) {
return this.procurementService.deleteDisposal(id);

View File

@@ -3,7 +3,8 @@ import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swa
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingView } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
import { ReportQueryDto } from './dto/report-query.dto';
import { ReportResultDto } from './dto/report-result.dto';
@@ -19,7 +20,7 @@ export class ReportsController {
) {}
@Get(':key')
@BookingView()
@BookingStaff(FREIGHT_PERMS.reports.view)
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
@ApiOkResponse({ type: ReportResultDto })
async run(

View File

@@ -5,7 +5,7 @@ import {
import { ApiBearerAuth, ApiOperation, 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 { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { RuleEngineApprove, RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView } from '../../../common/rule-engine-guards';
import { isSuperAdmin } from '../../../common/freight-permission.util';
import { CreateRateDto } from '../dto/create-rate.dto';
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
@@ -68,7 +68,7 @@ export class RatesController {
}
@Post(':id/approve')
@RuleEngineUpdate('rates')
@RuleEngineApprove('rates')
@ApiOperation({ summary: 'CEO approves a rate' })
approve(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -18,10 +18,12 @@ import {
} from "@nestjs/swagger";
import { Response } from "express";
import { MixedAudience } from "../../common/booking-guards";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { FilesService } from "../files/files.service";
import { SupportChatService } from "./support-chat.service";
@@ -48,6 +50,7 @@ export class SupportAttachmentController {
) {}
@Get(":fileId")
@MixedAudience(FREIGHT_PERMS.support.agentView)
@ApiOperation({
summary: "Download a support chat attachment",
description:

View File

@@ -17,10 +17,12 @@ import {
import { FilesInterceptor } from "@nestjs/platform-express";
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
@@ -38,12 +40,14 @@ export class SupportChatAgentController {
constructor(private readonly service: SupportChatService) {}
@Get("conversations")
@BookingStaff(FREIGHT_PERMS.support.agentView)
@ApiOperation({ summary: "List all support threads (shared inbox)" })
list(@Query() query: ListConversationsQueryDto) {
return this.service.listForAgents(query);
}
@Post("conversations")
@BookingStaff(FREIGHT_PERMS.support.agentSend)
@ApiOperation({
summary: "Start chatting with a company (returns the thread if one exists)",
})
@@ -52,6 +56,7 @@ export class SupportChatAgentController {
}
@Get("conversations/:id/messages")
@BookingStaff(FREIGHT_PERMS.support.agentView)
@ApiOperation({
summary: "List messages in a thread (newest page first)",
description:
@@ -67,6 +72,7 @@ export class SupportChatAgentController {
}
@Post("conversations/:id/messages")
@BookingStaff(FREIGHT_PERMS.support.agentSend)
@UseInterceptors(
FilesInterceptor(
SUPPORT_ATTACHMENT_FIELD,
@@ -106,6 +112,7 @@ export class SupportChatAgentController {
}
@Post("conversations/:id/read")
@BookingStaff(FREIGHT_PERMS.support.agentView)
@ApiOperation({ summary: "Mark a thread read (agent side)" })
read(
@CurrentUser() user: AuthUserPayload,
@@ -115,6 +122,7 @@ export class SupportChatAgentController {
}
@Get("unread-count")
@BookingStaff(FREIGHT_PERMS.support.agentView)
@ApiOperation({ summary: "Count unread threads (agent side)" })
unread(@CurrentUser() user: AuthUserPayload) {
return this.service.unreadCount(

View File

@@ -23,6 +23,7 @@ import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
} from "./attachment-upload.options";
import { PortalCustomer } from "../../common/booking-guards";
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
import { SendMessageDto } from "./dto/send-message.dto";
import { SupportChatService } from "./support-chat.service";
@@ -34,6 +35,7 @@ import { SupportChatService } from "./support-chat.service";
*/
@ApiTags("support-chat")
@Controller("support")
@PortalCustomer()
export class SupportChatController {
constructor(private readonly service: SupportChatService) {}

View File

@@ -10,6 +10,9 @@ import {
import { CurrentUser } from "@edr/api-common";
import {
BookingDocReviewAlert,
BookingStaff,
MixedAudience,
PortalCustomer,
TrainSchedulingCancel,
TrainSchedulingCreate,
TrainSchedulingReschedule,
@@ -17,6 +20,7 @@ import {
TrainSchedulingUpdate,
TrainSchedulingView,
} from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
@@ -71,6 +75,7 @@ export class TrainSchedulingController {
) { }
@Get("my-booking-windows")
@PortalCustomer()
@ApiOperation({
summary:
"Upcoming/open booking windows announced to the signed-in customer (all window-engine schedules; their own contract lanes carry a Book-now target)",
@@ -85,6 +90,10 @@ export class TrainSchedulingController {
}
@Get("contracts/:contractId/booking-windows")
@MixedAudience([
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.contracts.createBooking,
])
@ApiOperation({
summary:
"Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL",
@@ -496,14 +505,14 @@ export class TrainSchedulingController {
}
@Post("schedules/:id/finalize")
@TrainSchedulingUpdate()
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
@ApiOperation({ summary: "Finalize a draft train schedule" })
finalizeSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.finalizeSchedule(id);
}
@Post("schedules/:id/dispatch")
@TrainSchedulingUpdate()
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
@ApiOperation({ summary: "Dispatch a scheduled train" })
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.dispatchSchedule(id);
@@ -824,7 +833,7 @@ export class TrainSchedulingController {
}
@Post("bookings/:bookingId/mark-paid")
@TrainSchedulingUpdate()
@BookingStaff(FREIGHT_PERMS.trainScheduling.markPaid)
@ApiOperation({
summary: "Staff: mark a reserved booking paid and allocate it now",
})
@@ -834,7 +843,7 @@ export class TrainSchedulingController {
}
@Post("bookings/:bookingId/expire")
@TrainSchedulingUpdate()
@BookingStaff(FREIGHT_PERMS.trainScheduling.expireBooking)
@ApiOperation({
summary: "Staff: expire a reservation and free its capacity",
})

View File

@@ -11,8 +11,9 @@ import { ApiBearerAuth, ApiOperation, 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 { StaffReference } from '../../common/booking-guards';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { isFreightApprovalAdmin } from '../../common/freight-permission.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto';
import { UserTradeAccessService } from './user-trade-access.service';
@@ -24,6 +25,7 @@ export class UserTradeAccessController {
constructor(private readonly service: UserTradeAccessService) {}
@Get()
@BookingStaff(FREIGHT_PERMS.tradeAccess.view)
@ApiOperation({ summary: 'List every configured user trade-direction scope' })
list(@CurrentUser() user: TCurrentUser) {
this.assertAdmin(user);
@@ -41,6 +43,7 @@ export class UserTradeAccessController {
}
@Put(':userId')
@BookingStaff(FREIGHT_PERMS.tradeAccess.manage)
@ApiOperation({
summary: 'Set the trade directions a backoffice user may see',
})

View File

@@ -41,7 +41,9 @@ const toInt = (value?: string): number | undefined => {
*/
@ApiTags('wagon-transfer-requests')
@Controller('wagon-transfer-requests')
@WagonTransferView()
// 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) {}
@@ -56,6 +58,7 @@ export class WagonTransferRequestsController {
}
@Get()
@WagonTransferView()
@ApiOperation({
summary:
'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type',
@@ -84,6 +87,7 @@ export class WagonTransferRequestsController {
// 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({
@@ -129,6 +133,7 @@ export class WagonTransferRequestsController {
}
@Get(':id')
@WagonTransferView()
@ApiOperation({ summary: 'Get one transfer request' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);

View File

@@ -17,7 +17,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import {
BookingStaff,
FleetManage,
StaffReference,
FleetView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWagonDto } from './dto/create-wagon.dto';
@@ -44,7 +44,7 @@ export class WagonsController {
}
@Get()
@StaffReference()
@FleetView(FREIGHT_PERMS.wagons.view)
@ApiOperation({
summary: 'List wagons, paginated ({items, meta}) — 10 per page by default',
})
@@ -53,14 +53,14 @@ export class WagonsController {
}
@Get(':id')
@StaffReference()
@FleetView(FREIGHT_PERMS.wagons.view)
@ApiOperation({ summary: 'Get a wagon by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.findById(id);
}
@Get(':id/movements')
@StaffReference()
@FleetView(FREIGHT_PERMS.wagons.view)
@ApiOperation({
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
})

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -457,7 +457,7 @@ export class WarehouseInventoryController {
}
@Get(':id/handover-document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
@@ -468,7 +468,7 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/approve-delivery')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -484,14 +484,14 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handovers')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Post('handovers/:handoverId/sign')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
signHandover(
@Param('handoverId', ParseUUIDPipe) handoverId: string,
@@ -507,14 +507,14 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/request-handover-signature')
@StaffReference()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/grn-document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
@@ -525,7 +525,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/release-document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
@@ -536,7 +536,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handover-document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
async bookingHandoverDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -571,21 +571,21 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/container-items')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Get('bookings/:bookingId/container-weights')
@StaffReference()
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingContainerWeights(bookingId);
}
@Get('bookings/:bookingId/location')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingLocation(bookingId);

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { BookingStaff, MixedAudience } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
@@ -43,7 +43,7 @@ export class WarehouseInvoiceController {
}
@Get('bookings/:id/warehouse-fee-invoices')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForBooking(id);
@@ -71,14 +71,14 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.findById(id);
}
@Get('warehouse-fee-invoices/:id/document')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.document(id);
@@ -89,7 +89,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id/receipt')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.view)
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.receipt(id);
@@ -114,7 +114,7 @@ export class WarehouseInvoiceController {
}
@Post('warehouse-fee-invoices/:id/pay-online')
@StaffReference()
@MixedAudience(FREIGHT_PERMS.warehouseFeeInvoices.pay)
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
return this.invoiceService.initiatePayment(id, dto);

View File

@@ -14,13 +14,19 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
// Baseline read: warehouse reference data is consumed by inventory/dashboard
// flows too, so any of the three view permissions grants reads. Writes stack
// their specific create/update permission per route on top.
// flows too, so any of the view permissions grants reads. Writes stack their
// specific create/update permission per route on top — which means every key
// used by a route below must also appear here, or the class guard denies
// before the route's own key is ever consulted (Nest ANDs the two).
@Controller('warehouses')
@BookingStaff([
FREIGHT_PERMS.warehouses.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseDashboard.view,
FREIGHT_PERMS.warehouses.create,
FREIGHT_PERMS.warehouses.update,
FREIGHT_PERMS.warehouseYards.view,
FREIGHT_PERMS.warehouseYards.create,
])
export class WarehousesController {
constructor(