diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 16ee9cd57..96af26034 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -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 diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c032f2feb..c98990d25 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index aba4ce495..49bd31c61 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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() 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); diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts index 68def6440..db6275c07 100644 --- a/apps/edr-freight-api/src/common/freight-permission.guard.ts +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -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 { + @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; +} diff --git a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts index 9165e54d5..2d2863dd5 100644 --- a/apps/edr-freight-api/src/common/guards/service-auth.guard.ts +++ b/apps/edr-freight-api/src/common/guards/service-auth.guard.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/ai/ai.controller.ts b/apps/edr-freight-api/src/modules/ai/ai.controller.ts index eb87fe97d..d71795066 100644 --- a/apps/edr-freight-api/src/modules/ai/ai.controller.ts +++ b/apps/edr-freight-api/src/modules/ai/ai.controller.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/auth/list-users.controller.ts b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts index e7fbfd771..e2f06f065 100644 --- a/apps/edr-freight-api/src/modules/auth/list-users.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts @@ -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', }) diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index b3305ba68..d25fb74ba 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 49772316a..d72528310 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts index 981233df0..90f6a32a4 100644 --- a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -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) {} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index cad7c6061..a4ba83dd9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -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 { @@ -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 1–2 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); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 3f04bcd7a..658629592 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 1c9d75487..810d4d46a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -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)" }) diff --git a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts index 2a5715647..0e5949300 100644 --- a/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts +++ b/apps/edr-freight-api/src/modules/compliance/compliance.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts index 8cf1c5671..6cba17f61 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 706d3cdee..6273be365 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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); diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts deleted file mode 100644 index 6f425e1bb..000000000 --- a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts +++ /dev/null @@ -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" }; - } -} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts deleted file mode 100644 index db73ed728..000000000 --- a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from "@nestjs/common"; - -import { DemoPermissionsController } from "./demo-permissions.controller"; - -@Module({ - controllers: [DemoPermissionsController], -}) -export class DemoPermissionsModule {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts index aad904557..aaf552f40 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index a6c08a317..001fc90d2 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -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)", diff --git a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts index 25fbbc365..451c4d03b 100644 --- a/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts +++ b/apps/edr-freight-api/src/modules/facilities/facilities.controller.ts @@ -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 { 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 { 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 { return this.facilitiesService.remove(id); diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts index 661339902..ab4ae98f0 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index fc58a731c..139da9fbd 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -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 (/