mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1153 from Tria-plc/freight/nati-2
Permission enhancement
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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 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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)" })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { DemoPermissionsController } from "./demo-permissions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [DemoPermissionsController],
|
||||
})
|
||||
export class DemoPermissionsModule {}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
BIN
apps/edr-freight-web/portal/public/assets/edr-portal-guide.webm
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr-portal-guide.webm
Normal file
Binary file not shown.
@@ -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">
|
||||
|
||||
119
deploy/position-type-grant-mapping.sql
Normal file
119
deploy/position-type-grant-mapping.sql
Normal 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;
|
||||
Reference in New Issue
Block a user