Merge pull request #1153 from Tria-plc/freight/nati-2

Permission enhancement
This commit is contained in:
Nathnael Wondisha
2026-08-07 11:12:48 +03:00
committed by GitHub
58 changed files with 2280 additions and 927 deletions

View File

@@ -122,3 +122,7 @@ FAYDA_SESSION_TTL_MINUTES=10
EXPIRATION_TIME=15
ALGORITHM=RS256
EMAIL_QUEUE=email_queue
# Shared secret for service-to-service calls (payment microservice <-> freight).
# Required at boot; set ALLOW_UNAUTH_INTERNAL=true instead ONLY for local dev.
SERVICE_AUTH_TOKEN=change-me

View File

@@ -53,7 +53,6 @@ import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
import { FreightAuthModule } from "./modules/auth/freight-auth.module";
import {
EDR_FREIGHT_APPLICATION,
@@ -100,6 +99,7 @@ import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module";
import { IncidentsModule } from "./modules/incidents/incidents.module";
import { ProcurementModule } from "./modules/procurement/procurement.module";
import { FacilitiesModule } from "./modules/facilities/facilities.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
@@ -220,7 +220,6 @@ if (!process.env.APPLICATION_NAME) {
HealthModule,
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
FreightAuthModule,
PaymentModule,
//New Modules
@@ -240,6 +239,7 @@ if (!process.env.APPLICATION_NAME) {
ComplianceModule,
IncidentsModule,
ProcurementModule,
FacilitiesModule,
GpsTrackingModule,
FirstMileModule,
LastMileModule,

View File

@@ -1,7 +1,11 @@
import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import {
FreightPermissionGuard,
MixedAudienceGuard,
PortalCustomerGuard,
} from './freight-permission.guard';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
export const BookingStaff = (permission: string | string[]) =>
@@ -18,8 +22,30 @@ export const BookingStaff = (permission: string | string[]) =>
* Read-only reference data (yard dropdowns, search filters): any signed-in
* staff. Menu/page visibility stays permission-gated in the frontend — this
* only lets forms populate their lookups.
* Deprecated for new routes — it never checked the caller was staff. Prefer
* BookingStaff(<view key>) or MixedAudience(); kept for routes not yet swept.
*/
export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
export const StaffReference = () =>
applyDecorators(UseGuards(JwtGuard, FreightPermissionGuard([])));
/** Portal routes: customer accounts only; ownership scoping stays in services. */
export const PortalCustomer = () =>
applyDecorators(UseGuards(JwtGuard, PortalCustomerGuard));
/**
* Routes both audiences call (sign, shared document reads, handover): staff
* need one of the given permissions, customers pass through to the service's
* ownership checks.
*/
export const MixedAudience = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
MixedAudienceGuard(
Array.isArray(permission) ? permission : [permission],
),
),
);
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);

View File

@@ -8,7 +8,19 @@ import {
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { hasFreightPermission } from './freight-permission.util';
import { hasFreightPermission, isSuperAdmin } from './freight-permission.util';
// String literals on purpose (same reasoning as login-audience.middleware.ts):
// the values are wire-format constants from iam.users.user_type, and importing
// the vendored enum couples us to its package layout for no gain.
const CUSTOMER_USER_TYPES = ['individual', 'external_organization'];
const userTypeOf = (user: TCurrentUser): string | undefined =>
(user as { userType?: string }).userType;
/** Staff routes are employee-only; a missing userType (stale session) also fails. */
const isEmployee = (user: TCurrentUser): boolean =>
userTypeOf(user) === 'employee' || isSuperAdmin(user);
export function FreightPermissionGuard(
permissions: string[],
@@ -19,11 +31,14 @@ export function FreightPermissionGuard(
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!permissions?.length) return true;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (!isEmployee(user)) {
throw new ForbiddenException('Staff account required');
}
if (!permissions?.length) return true;
if (permissions.some((p) => hasFreightPermission(user, p))) {
return true;
}
@@ -36,3 +51,57 @@ export function FreightPermissionGuard(
return FreightPermissionsGuard;
}
/** Portal routes: customer accounts only (individual / external organization). */
@Injectable()
export class PortalCustomerGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (!CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) {
throw new ForbiddenException('Customer account required');
}
return true;
}
}
/**
* Routes both audiences legitimately call (contract sign, shared document
* reads, warehouse handover). Staff callers must hold one of the given
* permissions; customer callers pass here and are scoped by the service's
* ownership checks.
*/
export function MixedAudienceGuard(permissions: string[]): Type<CanActivate> {
@Injectable()
class MixedAudiencesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
if (CUSTOMER_USER_TYPES.includes(userTypeOf(user) ?? '')) {
return true;
}
if (!isEmployee(user)) {
throw new ForbiddenException('Unrecognized account type');
}
if (
!permissions?.length ||
permissions.some((p) => hasFreightPermission(user, p))
) {
return true;
}
throw new ForbiddenException(
`Missing permission. Required one of: ${permissions.join(', ')}`,
);
}
}
return MixedAudiencesGuard;
}

View File

@@ -20,8 +20,12 @@ export class ServiceAuthGuard implements CanActivate {
private warned = false;
constructor() {
if (!this.token && process.env.NODE_ENV === "production") {
throw new Error("SERVICE_AUTH_TOKEN must be set in production");
// Fail closed everywhere: a missing secret must never silently open the
// internal payment surface. Local dev can opt out explicitly.
if (!this.token && process.env.ALLOW_UNAUTH_INTERNAL !== "true") {
throw new Error(
"SERVICE_AUTH_TOKEN must be set (or ALLOW_UNAUTH_INTERNAL=true for local dev)",
);
}
}
@@ -29,7 +33,7 @@ export class ServiceAuthGuard implements CanActivate {
if (!this.token) {
if (!this.warned) {
this.logger.warn(
"SERVICE_AUTH_TOKEN unset — internal endpoints are UNGUARDED (dev only)",
"ALLOW_UNAUTH_INTERNAL=true — internal endpoints are UNGUARDED (dev only)",
);
this.warned = true;
}

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

@@ -15,17 +15,11 @@ 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,
WagonCancellationView,
} from '../../common/booking-guards';
import { BookingStaff, BookingView, MixedAudience, PortalCustomer } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import {
@@ -167,6 +161,7 @@ export class BookingsController {
) {}
@Post()
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
@@ -207,6 +202,7 @@ export class BookingsController {
}
@Patch(":id")
@MixedAudience([])
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -223,6 +219,7 @@ export class BookingsController {
}
@Get()
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({ summary: "List freight bookings (paginated)" })
async findAll(
@Query() filter: FilterBookingDto,
@@ -298,6 +295,7 @@ export class BookingsController {
}
@Get("my")
@PortalCustomer()
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
@@ -328,6 +326,7 @@ export class BookingsController {
}
@Get("reference-data")
@MixedAudience([])
@ApiOperation({ summary: "Booking form catalog" })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
@@ -335,6 +334,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 +352,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 +374,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 +397,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 +423,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 +451,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 +467,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 +494,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 +659,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 +677,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 +692,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 +707,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 +723,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 +738,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 +752,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 +767,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 +785,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 +799,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 +817,7 @@ export class BookingsController {
}
@Get(':id/tracking')
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary: "Shipment tracking timeline for a booking",
description:
@@ -818,6 +840,7 @@ export class BookingsController {
}
@Delete(":id")
@MixedAudience([])
@HttpCode(204)
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
remove(@Param("id", ParseUUIDPipe) id: string) {
@@ -825,6 +848,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 +861,7 @@ export class BookingsController {
}
@Post(":id/generate-price")
@MixedAudience([])
@ApiOperation({
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
description:
@@ -848,6 +873,7 @@ export class BookingsController {
}
@Post(":id/submit")
@MixedAudience([])
@ApiOperation({
summary: "Customer submit booking",
description:
@@ -859,6 +885,7 @@ export class BookingsController {
}
@Post(":id/confirm-submit")
@MixedAudience([])
@ApiOperation({
summary: "Confirm submit after price change",
description:
@@ -870,6 +897,7 @@ export class BookingsController {
}
@Post(":id/reject")
@PortalCustomer()
@ApiOperation({
summary: "Customer reject price estimate",
description:
@@ -900,6 +928,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 +938,7 @@ export class BookingsController {
}
@Post(":id/clearance/documents")
@PortalCustomer()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
@@ -925,7 +955,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 +977,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 +1179,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 +1190,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 +1217,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 +1369,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 +1393,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 +1405,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 +1421,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 +1431,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 +1452,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 +1547,7 @@ export class BookingsController {
}
@Post(":id/cancel-hold")
@PortalCustomer()
@ApiOperation({
summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
@@ -1518,18 +1562,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(

View File

@@ -1,5 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { Permission } from '@tria-plc/iamapi-common';
import {
Permission,
PositionPermission,
PositionTypePermission,
RolePermission,
} from '@tria-plc/iamapi-common';
import { DataSource } from 'typeorm';
/** Renamed rule-engine resources: old key -> new key (same permission id). */
@@ -14,6 +19,22 @@ const PERMISSION_KEY_RENAMES: ReadonlyArray<{ from: string; to: string }> = [
},
];
/**
* Keys retired by the permission-system redesign (docs/permission-system/01):
* seeded but never enforced anywhere, and no matching feature exists. Grants
* referencing them are revoked before the permission row is deleted.
*/
const RETIRED_PERMISSION_KEYS: ReadonlyArray<string> = [
'edr_freight_app:payments:verify',
'edr_freight_app:payments:refund',
'edr_freight_app:invoices:create',
'edr_freight_app:invoices:cancel',
'edr_freight_app:fuel:approve',
'edr_freight_app:maintenance:complete',
'edr_freight_app:bookings:payment_pnr',
'edr_freight_app:bookings:payment_verify',
];
@Injectable()
export class FreightPermissionKeyMigrationSeeder {
private readonly logger = new Logger(FreightPermissionKeyMigrationSeeder.name);
@@ -44,5 +65,29 @@ export class FreightPermissionKeyMigrationSeeder {
await permissionRepository.update({ id: existing.id }, { key: to });
this.logger.log(`Renamed permission key ${from} -> ${to}`);
}
for (const key of RETIRED_PERMISSION_KEYS) {
const existing = await permissionRepository.findOne({
where: { key },
select: { id: true },
});
if (!existing) {
continue;
}
// Revoke every grant first, then drop the permission row itself.
const permissionId = existing.id as string;
await this.dataSource
.getRepository(RolePermission)
.delete({ permissionId });
await this.dataSource
.getRepository(PositionPermission)
.delete({ permissionId });
await this.dataSource
.getRepository(PositionTypePermission)
.delete({ permissionId });
await permissionRepository.delete({ id: permissionId });
this.logger.log(`Retired permission key ${key}`);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -166,7 +166,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Reports",
href: "/dashboard/reports",
icon: <BarChart3 />,
permission: FREIGHT_PERMS.bookings.view,
permission: FREIGHT_PERMS.reports.view,
},
{
label: "Customers",
@@ -204,19 +204,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Payments",
href: "/dashboard/payments",
icon: <Wallet />,
permission: FREIGHT_PERMS.bookings.view,
permission: FREIGHT_PERMS.payments.view,
},
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
permission: FREIGHT_PERMS.bookings.view,
permission: FREIGHT_PERMS.invoices.view,
},
{
label: "Support",
href: "/dashboard/support",
icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.view,
permission: FREIGHT_PERMS.support.agentView,
},
...demoItems,
],
@@ -852,26 +852,30 @@ const App = () => {
element={<Navigate to="/dashboard/overview" replace />}
/>
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="reports" element={<ReportsHubPage />} />
<Route path="reports/:reportKey" element={<ReportPage />} />
<Route path="overview" element={<RequirePermission permission={FREIGHT_PERMS.overview.view}><OverviewPage /></RequirePermission>} />
<Route path="reports" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportsHubPage /></RequirePermission>} />
<Route path="reports/:reportKey" element={<RequirePermission permission={FREIGHT_PERMS.reports.view}><ReportPage /></RequirePermission>} />
{/* Dev/testing page for the mock AI booking assistant. */}
<Route
path="ai-booking-mock-test"
element={<AiBookingMockTestPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<AiBookingMockTestPage />
</RequirePermission>
}
/>
<Route path="profile" element={<MyProfilePage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><BookingRequestsPage /></RequirePermission>} />
<Route
path="payments"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<RequirePermission permission={FREIGHT_PERMS.payments.view}>
<PaymentsPage />
</RequirePermission>
}
/>
<Route path="support" element={<SupportInboxPage />} />
<Route path="support" element={<RequirePermission permission={FREIGHT_PERMS.support.agentView}><SupportInboxPage /></RequirePermission>} />
<Route
path="customers"
element={
@@ -891,7 +895,7 @@ const App = () => {
<Route
path="invoices"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoicesPage />
</RequirePermission>
}
@@ -899,12 +903,12 @@ const App = () => {
<Route
path="invoices/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
<InvoiceDetailPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/new" element={<RequirePermission permission={FREIGHT_PERMS.bookings.view}><NewBookingPage /></RequirePermission>} />
<Route
path="wagon-cancellations"
element={
@@ -917,11 +921,19 @@ const App = () => {
/>
<Route
path="booking-requests/:id"
element={<BookingRequestDetailPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingRequestDetailPage />
</RequirePermission>
}
/>
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<BookingContractPage />
</RequirePermission>
}
/>
{/* Legacy booking-based clearance URLs → the contract clearance hub. */}
<Route
@@ -1109,44 +1121,26 @@ const App = () => {
path="bookings/:id/milestones"
element={<BookingMilestonesRedirect />}
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route
path="warehouse-inventory"
element={<WarehouseInventoryPage />}
/>
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="import-trucks" element={<ImportTrucksPage />} />
<Route
path="edr-last-mile-returns"
element={<EDRLastMileReturnsPage />}
/>
<Route path="container-returns" element={<ContainerReturnsPage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route
path="export-djibouti-unloading"
element={<ExportDjiboutiUnloadingQueuePage />}
/>
<Route
path="interchange-documents"
element={<InterchangeDocumentsPage />}
/>
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route
path="warehouse-fee-invoices"
element={<WarehouseInvoicesPage />}
/>
<Route
path="warehouse-dashboard"
element={<WarehouseDashboardPage />}
/>
<Route path="warehouses" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseListPage /></RequirePermission>} />
<Route path="warehouses/:id" element={<RequirePermission permission={FREIGHT_PERMS.warehouses.view}><WarehouseDetailPage /></RequirePermission>} />
<Route path="warehouse-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><WarehouseInventoryPage /></RequirePermission>} />
<Route path="import-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportWarehouseFlowPage /></RequirePermission>} />
<Route path="export-warehouse" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportWarehouseFlowPage /></RequirePermission>} />
<Route path="arrival-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ArrivalQueuePage /></RequirePermission>} />
<Route path="loading-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadingQueuePage /></RequirePermission>} />
<Route path="intercity" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><IntercityPage /></RequirePermission>} />
<Route path="trucks-on-site" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><TrucksOnSitePage /></RequirePermission>} />
<Route path="import-trucks" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ImportTrucksPage /></RequirePermission>} />
<Route path="edr-last-mile-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><EDRLastMileReturnsPage /></RequirePermission>} />
<Route path="container-returns" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ContainerReturnsPage /></RequirePermission>} />
<Route path="loaded-inventory" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><LoadedInventoryPage /></RequirePermission>} />
<Route path="dispatch-queue" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><DispatchQueuePage /></RequirePermission>} />
<Route path="export-djibouti-unloading" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><ExportDjiboutiUnloadingQueuePage /></RequirePermission>} />
<Route path="interchange-documents" element={<RequirePermission permission={FREIGHT_PERMS.interchangeDocuments.view}><InterchangeDocumentsPage /></RequirePermission>} />
<Route path="inventory-inquiry" element={<RequirePermission permission={FREIGHT_PERMS.warehouseInventory.view}><InventoryInquiryPage /></RequirePermission>} />
<Route path="warehouse-rules" element={<RequirePermission permission={[FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseFeeRules.view]}><WarehouseRulesPage /></RequirePermission>} />
<Route path="warehouse-fee-invoices" element={<RequirePermission permission={FREIGHT_PERMS.warehouseFeeInvoices.view}><WarehouseInvoicesPage /></RequirePermission>} />
<Route path="warehouse-dashboard" element={<RequirePermission permission={FREIGHT_PERMS.warehouseDashboard.view}><WarehouseDashboardPage /></RequirePermission>} />
<Route
path="operations/train-scheduling"
@@ -1354,62 +1348,6 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission
permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}
>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="fuel-purchases"
element={
@@ -1490,108 +1428,6 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="train-builder"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainBuilderListPage />
</RequirePermission>
}
/>
<Route
path="train-builder/:id"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<TrainBuilderDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission
permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="wagon-transfers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.wagons.transferView,
FREIGHT_PERMS.wagons.view,
]}
>
<WagonTransfersPage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.containers.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.cargoes.view,
FREIGHT_PERMS.fleet.view,
]}
>
<FleetResourcePage />
</RequirePermission>
}
/>
{/* Legacy embedded user management routes */}
{/* <Route path="user-management" element={<UserManagementPage />} />

View File

@@ -68,6 +68,8 @@ export function ClearanceOpsTabs({
Boolean(exchangeEntityId) &&
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions));
// Risk assignment + incident reporting hit bookings:operations endpoints.
const canOps = hasPermission(user, FREIGHT_PERMS.bookings.operations);
const hasTabs = (showOpsTabs && hasOps) || showDocuments || showExchange;
if (!hasTabs) {
@@ -98,12 +100,12 @@ export function ClearanceOpsTabs({
Document exchange
</Tabs.Tab>
) : null}
{showOpsTabs && riskMs ? (
{showOpsTabs && canOps && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
</Tabs.Tab>
) : null}
{showOpsTabs && bookingId ? (
{showOpsTabs && canOps && bookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
@@ -129,7 +131,7 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showOpsTabs && riskMs && bookingId ? (
{showOpsTabs && canOps && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
@@ -137,7 +139,7 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{showOpsTabs && bookingId ? (
{showOpsTabs && canOps && bookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
<Stack gap="sm">

View File

@@ -41,6 +41,8 @@ import {
fetchViewableFile,
} from "@/services/files.service";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useFileViewer } from "@/hooks/useFileViewer";
export interface ContractClearanceReviewSectionProps {
@@ -123,6 +125,21 @@ export function ContractClearanceReviewSection({
} | null>(null);
const { view, viewer } = useFileViewer();
// Mirror the API guards: Path A (self-clearance) actions need
// ops_clearance_review; Path B (customs) review needs clearance_review or
// the ET phased key. Without the matching key every action would 403 — show
// the audit view instead of dead buttons.
const { user } = useAuth();
const canReviewHere = selfClear
? hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview)
: hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview) ||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
// Finalize has its own API key on the customs path (contracts:finalize_clearance).
const canFinalizeHere = selfClear
? hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview)
: hasPermission(user, FREIGHT_PERMS.contracts.finalizeClearance);
readOnly = readOnly || !canReviewHere;
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
const { data: clearance, isLoading } = useQuery({
@@ -484,7 +501,7 @@ export function ContractClearanceReviewSection({
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
disabled={!clearance.allApproved || !canFinalizeHere}
loading={finalizeClearance.isPending}
onClick={() =>
finalizeClearance.mutate(undefined, {

View File

@@ -19,6 +19,8 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
interface ScheduleBatchPanelProps {
schedule: TrainScheduleDetail;
@@ -31,6 +33,8 @@ const windowColor: Record<string, string> = {
};
export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
const { user } = useAuth();
const canMarkPaid = hasPermission(user, FREIGHT_PERMS.trainScheduling.markPaid);
const { toast } = useToast();
const actions = {
runBatch: useMutation(api.trainScheduling.runBatch.mutationOptions()),
@@ -179,7 +183,7 @@ export function ScheduleBatchPanel({ schedule }: ScheduleBatchPanelProps) {
<Table.Td>
{!locked && (
<Group gap={6} justify="flex-end" wrap="nowrap">
{b.status !== "PAID" && (
{canMarkPaid && b.status !== "PAID" && (
<Button
size="compact-xs"
variant="light"

View File

@@ -6,7 +6,32 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:overview:view",
},
support: {
view: "edr_freight_app:support:view",
agentView: "edr_freight_app:support:agent_view",
agentSend: "edr_freight_app:support:agent_send",
},
reports: {
view: "edr_freight_app:reports:view",
},
procurement: {
view: "edr_freight_app:procurement:view",
vendorManage: "edr_freight_app:procurement:vendor_manage",
acquisitionManage: "edr_freight_app:procurement:acquisition_manage",
disposalManage: "edr_freight_app:procurement:disposal_manage",
},
compliance: {
view: "edr_freight_app:compliance:view",
manage: "edr_freight_app:compliance:manage",
},
facilities: {
view: "edr_freight_app:facilities:view",
manage: "edr_freight_app:facilities:manage",
},
tradeAccess: {
view: "edr_freight_app:trade_access:view",
manage: "edr_freight_app:trade_access:manage",
},
staffUsers: {
view: "edr_freight_app:staff:users:view",
},
bookings: {
view: "edr_freight_app:bookings:view",
@@ -27,6 +52,7 @@ export const FREIGHT_PERMS = {
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
governmentExpedite: "edr_freight_app:bookings:government_expedite",
wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view",
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
wagonCancellationRebook:
@@ -65,6 +91,9 @@ export const FREIGHT_PERMS = {
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
suspend: "edr_freight_app:contracts:suspend",
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
@@ -73,6 +102,9 @@ export const FREIGHT_PERMS = {
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
},
fleet: {
view: "edr_freight_app:fleet:view",
@@ -92,13 +124,9 @@ export const FREIGHT_PERMS = {
},
payments: {
view: "edr_freight_app:payments:view",
verify: "edr_freight_app:payments:verify",
refund: "edr_freight_app:payments:refund",
},
invoices: {
view: "edr_freight_app:invoices:view",
create: "edr_freight_app:invoices:create",
cancel: "edr_freight_app:invoices:cancel",
export: "edr_freight_app:invoices:export",
},
firstMile: {
@@ -195,14 +223,12 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:fuel:create",
update: "edr_freight_app:fuel:update",
delete: "edr_freight_app:fuel:delete",
approve: "edr_freight_app:fuel:approve",
},
maintenance: {
view: "edr_freight_app:maintenance:view",
create: "edr_freight_app:maintenance:create",
update: "edr_freight_app:maintenance:update",
delete: "edr_freight_app:maintenance:delete",
complete: "edr_freight_app:maintenance:complete",
},
fleetReports: {
view: "edr_freight_app:fleet_reports:view",
@@ -282,6 +308,14 @@ export const FREIGHT_PERMS = {
view: "edr_freight_app:settings:dropdown:view",
manage: "edr_freight_app:settings:dropdown:manage",
},
exchangeRate: {
view: "edr_freight_app:settings:exchange_rate:view",
manage: "edr_freight_app:settings:exchange_rate:manage",
},
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",
},
},
audit: {
view: "edr_freight_app:audit:view",
@@ -575,7 +609,9 @@ export function canViewScheduling(user: AuthUser | null | undefined): boolean {
}
/** Any train-scheduling write action (create / update / cancel / reschedule). */
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
export function canManageScheduling(
user: AuthUser | null | undefined,
): boolean {
return (
hasPermission(user, FREIGHT_PERMS.trainScheduling.create) ||
hasPermission(user, FREIGHT_PERMS.trainScheduling.update) ||

View File

@@ -13,6 +13,8 @@ import {
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -60,6 +62,8 @@ function InfoField({ label, value }: { label: string; value?: string | null }) {
}
export default function InvoiceDetailPage() {
const { user } = useAuth();
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [downloading, setDownloading] = useState(false);
@@ -124,6 +128,7 @@ export default function InvoiceDetailPage() {
size="lg"
radius="md"
aria-label="Download invoice"
disabled={!canExport}
loading={downloading}
onClick={() => void downloadDocument()}
>

View File

@@ -51,6 +51,8 @@ import {
} from "@/features/support/useSupport";
import { useSupportSocket } from "@/features/support/useSupportSocket";
import { customersService } from "@/services/customers.service";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
type ReadFilter = "ALL" | "UNREAD";
@@ -401,6 +403,8 @@ function ConversationThread({
fetchNextPage,
} = useMessages(conversation.id);
const send = useSendMessage(conversation.id);
const { user: agentUser } = useAuth();
const canSend = hasPermission(agentUser, FREIGHT_PERMS.support.agentSend);
const markRead = useMarkConversationRead();
const [draft, setDraft] = useState("");
const [dragging, setDragging] = useState(false);
@@ -656,7 +660,7 @@ function ConversationThread({
color="edr-green"
variant="filled"
loading={send.isPending}
disabled={!draft.trim() && attach.attachments.length === 0}
disabled={!canSend || (!draft.trim() && attach.attachments.length === 0)}
onClick={submit}
>
<Send size={18} />

View File

@@ -42,6 +42,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { KpiStrip, PageContainer } from "@/components/page";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
autoFillPlacements,
mergePlacementsWithSaved,
@@ -102,6 +104,7 @@ const parseError = (error: unknown, fallback: string) => {
};
export default function TrainScheduleV2DetailPage() {
const { user: authUser } = useAuth();
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
const [activeStep, setActiveStep] = useState(0);
@@ -394,7 +397,9 @@ export default function TrainScheduleV2DetailPage() {
: [];
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
const canDispatch = schedule.status === "SCHEDULED";
const canDispatch =
schedule.status === "SCHEDULED" &&
hasPermission(authUser, FREIGHT_PERMS.trainScheduling.dispatch);
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
// cargo staff never marked loaded. Both are warnings, not blockers — staff can

View File

@@ -63,7 +63,7 @@ import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
import { FREIGHT_PERMS, canCreateSchedule, hasPermission } from "@/lib/permissions";
import type {
CreateScheduleWindowRulePayload,
FreightType,
@@ -111,7 +111,7 @@ export default function TrainScheduleV2ListPage() {
const { toast } = useToast();
const { user } = useAuth();
const canCreate = canCreateSchedule(user);
const canUpdate = canUpdateSchedule(user);
const canDispatch = hasPermission(user, FREIGHT_PERMS.trainScheduling.dispatch);
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
@@ -491,7 +491,7 @@ export default function TrainScheduleV2ListPage() {
{/* Start the run. Same transition as the detail page's
Dispatch button — that page also shows unassigned-wagon
and not-loaded warnings, so it stays the fuller surface. */}
{canUpdate && schedule.status === "SCHEDULED" ? (
{canDispatch && schedule.status === "SCHEDULED" ? (
<Menu.Item
leftSection={<Play size={15} />}
onClick={() => setDispatchTarget(schedule)}

View File

@@ -38,6 +38,8 @@ import {
import { openPdfBlob } from '@/components/warehouses/pdf';
import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf';
import { extractErrorMessage } from '@/components/warehouses/options';
import { useAuth } from '@/auth/useAuth';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
DRAFT: 'gray',
@@ -171,6 +173,9 @@ export default function WarehouseInvoicesPage() {
}
function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const { user } = useAuth();
const mayRecordPayment = hasPermission(user, FREIGHT_PERMS.warehouseFeeInvoices.pay);
const canCancelInvoice = hasPermission(user, FREIGHT_PERMS.warehouseFeeInvoices.cancel);
const { toast } = useToast();
const navigate = useNavigate();
const { data: inv, isLoading } = useQuery(
@@ -430,9 +435,11 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
placeholder="Optional"
style={{ flex: 1 }}
/>
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
</Button>
{mayRecordPayment && (
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
</Button>
)}
</Group>
<Divider label="Record manual payment" labelPosition="left" />
@@ -456,9 +463,11 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
onChange={(e) => setDriverPhone(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
{mayRecordPayment && (
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
Pay
</Button>
)}
</Group>
</>
)}
@@ -506,7 +515,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
Gate clearance & exit paper
</Button>
)}
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && canCancelInvoice && (
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
Cancel invoice
</Button>

View File

@@ -72,8 +72,33 @@ export default function HelpPage() {
<DocShell
current="/help"
title="Help & Support"
subtitle="Get answers fast — browse the common topics, check the FAQ, or reach our team directly."
subtitle="Get answers fast — watch the walkthrough, browse the common topics, check the FAQ, or reach our team directly."
>
<section className="mb-12">
<h2 className="text-xl font-bold tracking-tight">
Portal walkthrough
</h2>
<p className="mt-2 leading-7 text-muted-foreground">
A guided tour of the portal registering your company, raising a
booking against a contract, and settling an invoice.
</p>
{/* preload="metadata" so the 28 MB file is not pulled on every visit;
the browser fetches it only once playback starts. */}
<video
controls
preload="metadata"
className="mt-6 w-full rounded-[32px] border border-border bg-black"
>
<source src="/assets/edr-portal-guide.webm" type="video/webm" />
Your browser cannot play this video. Download it at{" "}
<a href="/assets/edr-portal-guide.webm">
/assets/edr-portal-guide.webm
</a>
.
</video>
</section>
{/* Live chat is the fastest route, so lead with it. */}
<div className="rounded-[32px] border border-border bg-card p-8">
<div className="flex items-start gap-4">

View File

@@ -0,0 +1,119 @@
-- Position-type grant mapping for the granular permission system rollout.
-- Grants the NEW granular keys to every hand-curated position type that holds
-- the old broad key whose routes the new keys took over. Idempotent (unique
-- constraint on (position_type_id, permission_id) + ON CONFLICT DO NOTHING).
--
-- PREREQUISITE: run the seeded API once first (SEED_EDR_ORG=true) so
-- EdrOrgSeeder has created the new permission rows this script references.
-- Running it too early is not destructive but silently under-applies: keys that
-- do not exist yet simply match nothing (measured: 11 of 42 rows land pre-seed,
-- because invoices:view/export and payments:view already exist on dev). Re-run
-- after seeding — it is safe to run any number of times.
--
-- Verified 2026-08-07 on a virgin restore of the live dev DB: seeded boot, then
-- this script → 42 rows inserted, second run → 0 rows, final per-key grant
-- counts identical to the reference environment. Per-user API probes across 20
-- departmental test accounts confirm the keys resolve through /me and gate
-- routes correctly.
BEGIN;
-- Helper shape used throughout:
-- holders of <old key> => also grant <new keys>
-- 1. bookings:view holders => dashboard/read keys that replaced blanket access
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:bookings:view'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:overview:view',
'edr_freight_app:reports:view',
'edr_freight_app:invoices:view',
'edr_freight_app:invoices:export',
'edr_freight_app:payments:view'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 2. train_scheduling:update holders => the write actions split out of it
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:train_scheduling:update'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:train_scheduling:dispatch',
'edr_freight_app:train_scheduling:mark_paid',
'edr_freight_app:train_scheduling:expire_booking'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 3. GL Djibouti clearance holders => final-invoice raise + confirm
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:contracts:clearance_dj_actions'
JOIN iam.permissions pnew ON pnew.key IN (
'edr_freight_app:contracts:final_invoice_raise',
'edr_freight_app:contracts:final_invoice_confirm'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 4. GL Ethiopia clearance holders => final-invoice confirm
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key = 'edr_freight_app:contracts:clearance_et_actions'
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:final_invoice_confirm'
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 5. Contract-intake holders (any staff_accept flavour) => edit_document
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pnew.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pold ON pold.id = ptp.permission_id
AND pold.key IN (
'edr_freight_app:bookings:staff_accept',
'edr_freight_app:contracts:staff_accept:bulk',
'edr_freight_app:contracts:staff_accept:container'
)
JOIN iam.permissions pnew ON pnew.key = 'edr_freight_app:contracts:edit_document'
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 6. Support inbox ownership (decision 2026-08-07): marketing department types
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT pt.id, p.id
FROM iam.position_types pt
CROSS JOIN iam.permissions p
WHERE (pt.name::text ILIKE '%marketing%' OR pt.key ILIKE '%marketing%')
AND p.key IN (
'edr_freight_app:support:agent_view',
'edr_freight_app:support:agent_send'
)
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
-- 7. Companion view keys.
-- Most freight controllers carry a class-level `<module>:view` guard, and Nest
-- runs class AND method guards — so a type holding only `<module>:<action>` is
-- denied before the action key is ever checked. Grant the module's view key
-- alongside every action key the type already holds. View-only, so it widens
-- reads within a module the type already operates in, never across modules.
INSERT INTO iam.position_type_permissions (position_type_id, permission_id)
SELECT DISTINCT ptp.position_type_id, pview.id
FROM iam.position_type_permissions ptp
JOIN iam.permissions pact ON pact.id = ptp.permission_id
AND pact.key LIKE 'edr_freight_app:%'
JOIN iam.permissions pview ON pview.key = regexp_replace(pact.key, ':[^:]+$', ':view')
ON CONFLICT (position_type_id, permission_id) DO NOTHING;
COMMIT;
-- Verification: expected non-zero counts per new key after running.
-- SELECT p.key, count(*) FROM iam.position_type_permissions ptp
-- JOIN iam.permissions p ON p.id = ptp.permission_id
-- WHERE p.key IN ('edr_freight_app:overview:view','edr_freight_app:support:agent_view',
-- 'edr_freight_app:train_scheduling:dispatch','edr_freight_app:contracts:final_invoice_raise')
-- GROUP BY p.key;