From f4fd469643fd7d83b77639c52c01be064fd6beb3 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 22:29:58 +0000 Subject: [PATCH] per-user trade-direction access scope --- apps/edr-freight-api/src/app.module.ts | 2 + .../3150000000000-CreateUserTradeAccess.ts | 43 ++++ ...000000000-YardFacilityOriginDestination.ts | 45 ++++ .../src/modules/billing/billing.controller.ts | 22 +- .../src/modules/billing/billing.module.ts | 2 + .../src/modules/billing/billing.service.ts | 11 + .../booking-reference-data.service.ts | 33 ++- .../modules/bookings/bookings.controller.ts | 14 +- .../src/modules/bookings/bookings.module.ts | 2 + .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 2 + .../dto/booking-reference-data.dto.ts | 12 + .../modules/contracts/contracts.controller.ts | 14 +- .../src/modules/contracts/contracts.module.ts | 2 + .../modules/contracts/contracts.repository.ts | 6 + .../modules/contracts/contracts.service.ts | 2 + .../modules/overview/overview.controller.ts | 68 +++++- .../src/modules/overview/overview.module.ts | 2 + .../modules/overview/overview.repository.ts | 103 ++++++-- .../src/modules/overview/overview.service.ts | 71 +++--- .../src/modules/payment/payment.controller.ts | 14 +- .../src/modules/payment/payment.module.ts | 2 + .../src/modules/payment/payment.service.ts | 6 + .../entities/yard-facility.entity.ts | 21 ++ .../services/yard-facilities.service.ts | 60 ++++- .../train-scheduling/booking-batch.service.ts | 6 + .../train-scheduling.controller.ts | 36 ++- .../train-scheduling.module.ts | 2 + .../train-scheduling.service.ts | 14 +- .../dto/upsert-user-trade-access.dto.ts | 16 ++ .../entities/user-trade-access.entity.ts | 27 +++ .../user-trade-access/trade-scope.util.ts | 100 ++++++++ .../user-trade-access.controller.ts | 67 ++++++ .../user-trade-access.module.ts | 15 ++ .../user-trade-access.repository.ts | 23 ++ .../user-trade-access.service.ts | 76 ++++++ .../src/seed/yard-facilities.seeder.ts | 13 +- apps/edr-freight-web/backoffice/src/App.tsx | 14 ++ .../trainScheduling/Train3DVisualization.tsx | 222 ++++++++++++++++-- .../backoffice/src/hooks/useMyTradeAccess.ts | 32 +++ .../pages/bookings/BookingRequestsPage.tsx | 4 +- .../pages/configuration/TradeAccessPage.tsx | 200 ++++++++++++++++ .../contracts/ClearanceDocumentsPage.tsx | 4 +- .../pages/contracts/ContractRequestsPage.tsx | 4 +- .../src/services/userTradeAccess.service.ts | 43 ++++ .../new-contract-form/step4-route.tsx | 49 +++- packages/types/src/freight/index.ts | 26 ++ 47 files changed, 1451 insertions(+), 107 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts create mode 100644 apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useMyTradeAccess.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a3c09df97..66f64678a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -89,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module"; import { RoutesModule } from "./modules/routes/routes.module"; import { WarehousesModule } from "./modules/warehouses/warehouses.module"; import { OverviewModule } from "./modules/overview/overview.module"; +import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; import { FuelModule } from "./modules/fuel/fuel.module"; @@ -206,6 +207,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar RoutesModule, WarehousesModule, OverviewModule, + UserTradeAccessModule, VehiclesModule, DriversModule, FuelModule, diff --git a/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts b/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts new file mode 100644 index 000000000..9e7cba924 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3150000000000-CreateUserTradeAccess.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Per-backoffice-user trade-direction scope (import / export / intercity). + * A user with no row (or all three directions) is unrestricted. Admins + * (super_admin / organization_admin) bypass the scope entirely. + */ +export class CreateUserTradeAccess3150000000000 implements MigrationInterface { + name = 'CreateUserTradeAccess3150000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'user_trade_access', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + // IAM user id (iam.users) — no FK, iam schema is externally owned. + { name: 'user_id', type: 'uuid', isUnique: true }, + // Comma-separated subset of IMPORT,EXPORT,DOMESTIC (simple-array). + { name: 'directions', type: 'text', default: "''" }, + { name: 'updated_by_id', type: 'uuid', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.user_trade_access', + new TableIndex({ + name: 'idx_user_trade_access_user_id', + columnNames: ['user_id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.user_trade_access', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts b/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts new file mode 100644 index 000000000..fb361b702 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3170000000000-YardFacilityOriginDestination.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Handling a freight type is not the same as handling it in both directions. A + * facility can be equipped to load containers onto a train but have no yard + * space to receive and stage inbound ones, so the origin and destination sides + * are stated independently per freight type. + * + * Backfilled from `handles_container` / `handles_bulk` so every existing row + * keeps its current behaviour: a facility that handles a type today handles it + * on both sides until someone narrows it in the backoffice. New rows default + * false — an unconfigured facility offers nothing rather than silently + * offering everything. + */ +export class YardFacilityOriginDestination3170000000000 implements MigrationInterface { + name = 'YardFacilityOriginDestination3170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + ADD COLUMN IF NOT EXISTS has_container_facility_origin boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_bulk_facility_origin boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_container_facility_destination boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS has_bulk_facility_destination boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + UPDATE freight.yard_facilities + SET has_container_facility_origin = handles_container, + has_container_facility_destination = handles_container, + has_bulk_facility_origin = handles_bulk, + has_bulk_facility_destination = handles_bulk + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + DROP COLUMN IF EXISTS has_container_facility_origin, + DROP COLUMN IF EXISTS has_bulk_facility_origin, + DROP COLUMN IF EXISTS has_container_facility_destination, + DROP COLUMN IF EXISTS has_bulk_facility_destination + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index b9f0a74c0..49772316a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -9,7 +9,11 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + import { BookingView } from "../../common/booking-guards"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @@ -18,14 +22,26 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @BookingView() @ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor( + private readonly billingService: BillingService, + private readonly userTradeAccessService: UserTradeAccessService, + ) {} @Get("invoices") @ApiOperation({ summary: "List invoices (paginated, filterable by company/status/search)", }) - findAll(@Query() query: FilterInvoiceDto) { - return this.billingService.findAllPaginated(query); + async findAll( + @Query() query: FilterInvoiceDto, + @CurrentUser() user: TCurrentUser, + ) { + // Per-user trade-direction scope, applied via each invoice's source booking. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.billingService.findAllPaginated({ + ...query, + tradeDirections: allowed ?? undefined, + }); } @Get("invoices/:id") diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 771156fd3..a67e6f6ba 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; import { PortalBillingController } from "./portal-billing.controller"; import { PaymentController } from "./payment.controller"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { BillingService } from "./billing.service"; import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; @@ -19,6 +20,7 @@ import { CompaniesModule } from "../companies/companies.module"; forwardRef(() => PaymentModule), CompaniesModule, DocumentsModule, + UserTradeAccessModule, ], controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 850791f02..1cc2f0375 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -11,6 +11,7 @@ import { EventEmitter2 } from "@nestjs/event-emitter"; import { DataSource, EntityManager, In } from "typeorm"; import { CompaniesService } from "../companies/companies.service"; +import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; import { @@ -167,6 +168,8 @@ export class BillingService { search?: string; page?: number; pageSize?: number; + /** Per-user trade-direction scope, applied via the source booking. */ + tradeDirections?: string[]; } = {}, ): Promise<{ items: Invoice[]; total: number }> { const page = filter.page && filter.page > 0 ? filter.page : 1; @@ -196,6 +199,14 @@ export class BillingService { ); } + if (filter.tradeDirections) { + applyBookingRefDirectionScope( + qb, + "invoice.source_id", + filter.tradeDirections, + ); + } + const [items, total] = await qb.getManyAndCount(); return { items, total }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 06268342b..2c60b8771 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -22,6 +22,7 @@ import { IYardsRepository, YARDS_REPOSITORY, } from "../rule-engine/interfaces/yards.repository.interface"; +import { YardFacilitiesService } from "../rule-engine/services/yard-facilities.service"; import { BookingReferenceCargoTypeChildDto, BookingReferenceCargoTypeGroupDto, @@ -170,11 +171,18 @@ export class BookingReferenceDataService { private readonly shippingLinesRepository: IShippingLinesRepository, @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepository: ICargoTypesRepository, + private readonly yardFacilitiesService: YardFacilitiesService, ) { } async getReferenceData(): Promise { - const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] = - await Promise.all([ + const [ + yards, + containerTypes, + serviceTypes, + shippingLines, + cargoTypes, + facilityYards, + ] = await Promise.all([ this.yardsRepository.findAll({ where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, @@ -195,17 +203,30 @@ export class BookingReferenceDataService { where: { isActive: true }, order: { displayOrder: "ASC", code: "ASC" }, }), + this.yardFacilitiesService.listFacilityYards(), ]); + // Every active yard is still listed; a yard with no facility record simply + // reports no capability, so the forms drop it from the pickers themselves. + const facilityByYardId = new Map(facilityYards.map((f) => [f.yardId, f])); + return { - yard: yards.map( - (y): BookingReferenceYardDto => ({ + yard: yards.map((y): BookingReferenceYardDto => { + const facility = facilityByYardId.get(y.id); + return { id: y.id, name: y.label, code: y.code, country: y.country, - }), - ), + hasContainerFacilityOrigin: + facility?.hasContainerFacilityOrigin ?? false, + hasBulkFacilityOrigin: facility?.hasBulkFacilityOrigin ?? false, + hasContainerFacilityDestination: + facility?.hasContainerFacilityDestination ?? false, + hasBulkFacilityDestination: + facility?.hasBulkFacilityDestination ?? false, + }; + }), containers: groupContainersBySize(containerTypes), service: serviceTypes.map( (s): BookingReferenceServiceDto => ({ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 7b1f34f36..837d0d801 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -43,6 +43,8 @@ import { RoAmendmentDto, } from '../contracts/dto/phased-clearance.dto'; import { BookingReferenceDataService } from './booking-reference-data.service'; +import { scopedDirections } from '../user-trade-access/trade-scope.util'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; import { CreateBookingDto } from './dto/create-booking.dto'; @@ -150,6 +152,7 @@ export class BookingsController { private readonly containerReceiptService: ContainerReceiptService, private readonly firstMileService: FirstMileService, private readonly lastMileService: LastMileService, + private readonly userTradeAccessService: UserTradeAccessService, ) {} @Post() @@ -217,7 +220,16 @@ export class BookingsController { // Staff (backoffice) see every booking. Customers (portal) are always // force-scoped to their own company, regardless of any companyId they pass. if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { - return this.bookingsService.findAll(filter); + // Per-user trade-direction scope (import/export/intercity checkboxes). + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + const dirs = scopedDirections(allowed, filter.tradeDirection); + return this.bookingsService.findAll( + filter, + undefined, + undefined, + dirs ?? undefined, + ); } // Global Logistics has clearance:view but NOT bookings:view — it is scoped // to the customs document-clearance queue only and never sees the general diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 38d7d2ac6..2063bb01d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from "@nestjs/common"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { ConfigService } from "@nestjs/config"; import { TypeOrmModule } from "@nestjs/typeorm"; import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; @@ -80,6 +81,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; MinioModule, VehiclesModule, CompaniesModule, + UserTradeAccessModule, // CustomersModule, RuleEngineModule, FileUploadSettingsModule, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 78004f024..185a6aaa7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -17,6 +17,7 @@ import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; +import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -64,6 +65,8 @@ export interface BookingListFilterOptions { freightType?: string; bookingType?: string; tradeDirection?: string; + /** Per-user trade-direction scope — `[]` matches nothing. */ + tradeDirections?: string[]; paymentCurrency?: string; paymentStatus?: string; excludePaymentStatus?: string; @@ -1000,6 +1003,9 @@ export class BookingsRepository extends BaseRepository { tradeDirection: options.tradeDirection, }); } + if (options.tradeDirections) { + applyDirectionScope(qb, 'booking.trade_direction', options.tradeDirections); + } if (options.paymentCurrency) { qb.andWhere('booking.payment_currency = :paymentCurrency', { paymentCurrency: options.paymentCurrency, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b8f73dfc8..67ac73a58 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1619,6 +1619,7 @@ export class BookingsService { filter: FilterBookingDto, forceCompanyId?: string, forceCompanyProfileId?: string, + tradeDirections?: string[], ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -1638,6 +1639,7 @@ export class BookingsService { // ANDs both, so cross-company access is impossible. companyId: forceCompanyId ?? filter.companyId, companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, + tradeDirections, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index a793cc558..4c51d85a6 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -13,6 +13,18 @@ export class BookingReferenceYardDto { @ApiProperty({ example: 'Ethiopia' }) country!: string; + + @ApiProperty({ description: 'Can load containers onto a train here.' }) + hasContainerFacilityOrigin!: boolean; + + @ApiProperty({ description: 'Can load bulk cargo onto a train here.' }) + hasBulkFacilityOrigin!: boolean; + + @ApiProperty({ description: 'Can receive containers off a train here.' }) + hasContainerFacilityDestination!: boolean; + + @ApiProperty({ description: 'Can receive bulk cargo off a train here.' }) + hasBulkFacilityDestination!: boolean; } export class BookingReferenceContainerTypeDto { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 8c004ade0..ec61fdd4a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -59,6 +59,8 @@ import { GlOperationsService } from './gl-operations.service'; import { BookingRequestService } from './booking-request.service'; import { SignaturesService } from '../signatures/signatures.service'; import { BookingsService } from '../bookings/bookings.service'; +import { scopedDirections } from '../user-trade-access/trade-scope.util'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -108,6 +110,7 @@ export class ContractsController { private readonly glOperationsService: GlOperationsService, private readonly bookingRequestService: BookingRequestService, private readonly signaturesService: SignaturesService, + private readonly userTradeAccessService: UserTradeAccessService, private readonly bookingClearanceService: BookingClearanceService, private readonly bookingsService: BookingsService, ) {} @@ -210,7 +213,16 @@ export class ContractsController { hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { - return this.contractsService.findAll(filter); + // Per-user trade-direction scope (import/export/intercity checkboxes). + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + const dirs = scopedDirections(allowed, filter.tradeDirection); + return this.contractsService.findAll( + filter, + undefined, + undefined, + dirs ?? undefined, + ); } const userId = user?.id; if (!userId) throw new UnauthorizedException('Authentication required'); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 658acf39d..0e60462a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -1,4 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; @@ -89,6 +90,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum NotificationsModule, NotificationInboxModule, CompaniesModule, + UserTradeAccessModule, // Provides the admin-editable contract document templates consumed by // ContractDocumentViewModelBuilder when rendering contract PDFs. ContractTemplatesModule, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 49ef3a29f..a7fddd816 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -5,6 +5,7 @@ import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm' import { Booking } from '../bookings/entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; +import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { Contract } from './entities/contract.entity'; import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; @@ -38,6 +39,8 @@ export interface ContractListFilterOptions { serviceTypeId?: string; freightType?: string; tradeDirection?: string; + /** Per-user trade-direction scope — `[]` matches nothing. */ + tradeDirections?: string[]; paymentCurrency?: string; customsClearingEnabled?: boolean; /** true → only contracts with at least one uploaded clearance document. */ @@ -436,6 +439,9 @@ export class ContractsRepository extends BaseRepository { tradeDirection: options.tradeDirection, }); } + if (options.tradeDirections) { + applyDirectionScope(qb, 'contract.trade_direction', options.tradeDirections); + } if (options.paymentCurrency) { qb.andWhere('contract.payment_currency = :paymentCurrency', { paymentCurrency: options.paymentCurrency, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 5c0b5e29e..6e170f126 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -748,6 +748,7 @@ export class ContractsService { filter: FilterContractDto, forceCompanyId?: string, forceCompanyProfileId?: string, + tradeDirections?: string[], ): Promise { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -763,6 +764,7 @@ export class ContractsService { serviceTypeId: filter.serviceTypeId, freightType: filter.freightType, tradeDirection: filter.tradeDirection, + tradeDirections, paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index e63cc5d01..4e1ecc6d6 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -5,6 +5,8 @@ import { 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 { BookingView } from '../../common/booking-guards'; import { OverviewQueryDto } from './dto/overview-query.dto'; @@ -18,43 +20,79 @@ import { OverviewStaffTabDto, } from './dto/overview-tab-response.dto'; import { OverviewService } from './overview.service'; +import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; @ApiTags('Overview') @ApiBearerAuth() @Controller('overview') export class OverviewController { - constructor(private readonly overviewService: OverviewService) {} + constructor( + private readonly overviewService: OverviewService, + private readonly userTradeAccessService: UserTradeAccessService, + ) {} @Get() @BookingView() @ApiOperation({ summary: 'Aggregated dashboard summary for backoffice overview' }) @ApiOkResponse({ type: OverviewResponseDto }) - getDashboard(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getDashboard(query.range ?? '30d'); + async getDashboard( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getDashboard( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('bookings') @BookingView() @ApiOperation({ summary: 'Bookings tab metrics and charts' }) @ApiOkResponse({ type: OverviewBookingsTabDto }) - getBookingsTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getBookingsTab(query.range ?? '30d'); + async getBookingsTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getBookingsTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('contracts') @BookingView() @ApiOperation({ summary: 'Contracts tab metrics and charts' }) @ApiOkResponse({ type: OverviewContractsTabDto }) - getContractsTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getContractsTab(query.range ?? '30d'); + async getContractsTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getContractsTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('billing') @BookingView() @ApiOperation({ summary: 'Billing tab metrics and charts' }) @ApiOkResponse({ type: OverviewBillingTabDto }) - getBillingTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getBillingTab(query.range ?? '30d'); + async getBillingTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getBillingTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('operations') @@ -69,8 +107,16 @@ export class OverviewController { @BookingView() @ApiOperation({ summary: 'Customers tab metrics and charts' }) @ApiOkResponse({ type: OverviewCustomersTabDto }) - getCustomersTab(@Query() query: OverviewQueryDto): Promise { - return this.overviewService.getCustomersTab(query.range ?? '30d'); + async getCustomersTab( + @Query() query: OverviewQueryDto, + @CurrentUser() user: TCurrentUser, + ): Promise { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.overviewService.getCustomersTab( + query.range ?? '30d', + allowed ?? undefined, + ); } @Get('staff') diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index 43fcdac0f..ff19bb801 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -11,6 +11,7 @@ import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; import { Train } from "../trains/entities/train.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; import { OverviewController } from "./overview.controller"; import { OverviewRepository } from "./overview.repository"; import { OverviewService } from "./overview.service"; @@ -29,6 +30,7 @@ import { OverviewService } from "./overview.service"; Employee, User, ]), + UserTradeAccessModule, ], controllers: [OverviewController], providers: [OverviewService, OverviewRepository], diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 4e56f415f..a8957d760 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -24,6 +24,10 @@ import { OVERVIEW_URGENT_PRIORITY_THRESHOLD, } from "./overview.constants"; import { Company } from "../companies/entities/company.entity"; +import { + bookingRefScopeSql, + directionScopeSql, +} from "../user-trade-access/trade-scope.util"; /** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS = @@ -93,7 +97,8 @@ export class OverviewRepository { private readonly userRepository: Repository, ) { } - async getBookingKpis(): Promise { + async getBookingKpis(dirs?: string[]): Promise { + const scope = directionScopeSql("booking.trade_direction", dirs); const row = await this.bookingRepository .createQueryBuilder("booking") .select( @@ -118,6 +123,7 @@ export class OverviewRepository { ) .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], @@ -202,12 +208,13 @@ export class OverviewRepository { }; } - async getBillingKpis(): Promise<{ + async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; pendingPayments: number; successfulPaymentsMtd: number; }> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const revenueRow = await this.paymentRepository .createQueryBuilder("payment") .select( @@ -223,6 +230,7 @@ export class OverviewRepository { .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) + .andWhere(scope.sql, scope.params) .getRawOne>(); const pendingPayments = await this.paymentRepository @@ -230,6 +238,7 @@ export class OverviewRepository { .where("payment.status IN (:...statuses)", { statuses: ["action-required", "processing"], }) + .andWhere(scope.sql, scope.params) .getCount(); return { @@ -261,13 +270,16 @@ export class OverviewRepository { async getBookingTrend( days: number, + dirs?: string[], ): Promise<{ date: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("booking.created_at::date") .orderBy("booking.created_at::date", "ASC") @@ -279,13 +291,15 @@ export class OverviewRepository { })); } - async getStatusCounts(): Promise> { + async getStatusCounts(dirs?: string[]): Promise> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.status", "status") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .groupBy("booking.status") .getRawMany<{ status: string; count: string }>(); @@ -296,7 +310,9 @@ export class OverviewRepository { async getPaymentTrend( days: number, + dirs?: string[], ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select( @@ -316,6 +332,7 @@ export class OverviewRepository { `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) + .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); @@ -327,7 +344,11 @@ export class OverviewRepository { })); } - async getRecentBookings(limit: number): Promise { + async getRecentBookings( + limit: number, + dirs?: string[], + ): Promise { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .leftJoin("booking.company", "company") @@ -341,6 +362,7 @@ export class OverviewRepository { .addSelect("booking.created_at", "createdAt") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) .orderBy("booking.created_at", "DESC") .limit(limit) .getRawMany<{ @@ -366,9 +388,10 @@ export class OverviewRepository { })); } - async getBookingsByFreightType(): Promise< - { label: string; count: number }[] - > { + async getBookingsByFreightType( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.freight_type", "label") @@ -376,6 +399,7 @@ export class OverviewRepository { .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("booking.freight_type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -386,7 +410,10 @@ export class OverviewRepository { })); } - async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { + async getBookingsByCurrency( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.payment_currency", "label") @@ -394,6 +421,7 @@ export class OverviewRepository { .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("booking.payment_currency") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -404,11 +432,15 @@ export class OverviewRepository { })); } - async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { + async getPaymentsByStatus( + dirs?: string[], + ): Promise<{ status: string; count: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.status", "status") .addSelect("COUNT(*)::int", "count") + .where(scope.sql, scope.params) .groupBy("payment.status") .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); @@ -419,9 +451,12 @@ export class OverviewRepository { })); } - async getPaymentsByMethod(): Promise< + async getPaymentsByMethod( + dirs?: string[], + ): Promise< { method: string; count: number; amountEtb: number; amountUsd: number }[] > { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.method", "method") @@ -434,6 +469,7 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) + .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") .getRawMany<{ @@ -451,9 +487,10 @@ export class OverviewRepository { })); } - async getRevenueByCurrency(): Promise< - { currency: string; amount: number }[] - > { + async getRevenueByCurrency( + dirs?: string[], + ): Promise<{ currency: string; amount: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.currency", "currency") @@ -462,6 +499,7 @@ export class OverviewRepository { .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) + .andWhere(scope.sql, scope.params) .groupBy("payment.currency") .getRawMany<{ currency: string; amount: string }>(); @@ -556,7 +594,9 @@ export class OverviewRepository { async getTopCustomersByBookings( limit: number, + dirs?: string[], ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .leftJoin("booking.company", "company") @@ -564,6 +604,7 @@ export class OverviewRepository { .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("company.name") .orderBy("count", "DESC") .limit(limit) @@ -632,7 +673,8 @@ export class OverviewRepository { // ── Contracts (overview Contract tab) ────────────────────────────────────── - async getContractKpis(): Promise { + async getContractKpis(dirs?: string[]): Promise { + const scope = directionScopeSql("contract.trade_direction", dirs); const row = await this.contractRepository .createQueryBuilder("contract") .select( @@ -656,6 +698,7 @@ export class OverviewRepository { "createdToday", ) .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .setParameters({ closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES], @@ -673,24 +716,33 @@ export class OverviewRepository { }; } - async getContractStatusCounts(): Promise> { + async getContractStatusCounts( + dirs?: string[], + ): Promise> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.status", "status") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .groupBy("contract.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])); } - async getContractTrend(days: number): Promise<{ date: string; count: number }[]> { + async getContractTrend( + days: number, + dirs?: string[], + ): Promise<{ date: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("contract.created_at::date") .orderBy("contract.created_at::date", "ASC") @@ -699,13 +751,17 @@ export class OverviewRepository { return rows.map((row) => ({ date: row.date, count: Number(row.count) })); } - async getContractsByKind(): Promise<{ label: string; count: number }[]> { + async getContractsByKind( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.contract_kind", "label") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere("contract.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("contract.contract_kind") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -713,13 +769,17 @@ export class OverviewRepository { return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } - async getContractsByFreightType(): Promise<{ label: string; count: number }[]> { + async getContractsByFreightType( + dirs?: string[], + ): Promise<{ label: string; count: number }[]> { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.freight_type", "label") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere("contract.status != 'DRAFT'") + .andWhere(scope.sql, scope.params) .groupBy("contract.freight_type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); @@ -727,7 +787,11 @@ export class OverviewRepository { return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } - async getRecentContracts(limit: number): Promise { + async getRecentContracts( + limit: number, + dirs?: string[], + ): Promise { + const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .leftJoin("contract.company", "company") @@ -741,6 +805,7 @@ export class OverviewRepository { .addSelect("contract.contract_valid_until", "validUntil") .addSelect("contract.created_at", "createdAt") .where("contract.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) .orderBy("contract.created_at", "DESC") .limit(limit) .getRawMany<{ diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 8a7b71b69..4b1f5d6bf 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -40,7 +40,10 @@ export class OverviewService { return { bookingsByPipeline, bookingsByStatus }; } - async getDashboard(range: OverviewRangeQuery = '30d'): Promise { + async getDashboard( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [ @@ -55,16 +58,16 @@ export class OverviewService { paymentTrend, recentBookings, ] = await Promise.all([ - this.overviewRepository.getBookingKpis(), - this.overviewRepository.getContractKpis(), + this.overviewRepository.getBookingKpis(dirs), + this.overviewRepository.getContractKpis(dirs), this.overviewRepository.getOperationsKpis(), this.overviewRepository.getCustomerKpis(), - this.overviewRepository.getBillingKpis(), + this.overviewRepository.getBillingKpis(dirs), this.overviewRepository.getStaffKpis(), - this.overviewRepository.getBookingTrend(days), - this.overviewRepository.getStatusCounts(), - this.overviewRepository.getPaymentTrend(days), - this.overviewRepository.getRecentBookings(8), + this.overviewRepository.getBookingTrend(days, dirs), + this.overviewRepository.getStatusCounts(dirs), + this.overviewRepository.getPaymentTrend(days, dirs), + this.overviewRepository.getRecentBookings(8, dirs), ]); const { bookingsByPipeline, bookingsByStatus } = @@ -91,7 +94,10 @@ export class OverviewService { }; } - async getBookingsTab(range: OverviewRangeQuery = '30d'): Promise { + async getBookingsTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [ @@ -102,12 +108,12 @@ export class OverviewService { bookingsByCurrency, recentBookings, ] = await Promise.all([ - this.overviewRepository.getBookingKpis(), - this.overviewRepository.getBookingTrend(days), - this.overviewRepository.getStatusCounts(), - this.overviewRepository.getBookingsByFreightType(), - this.overviewRepository.getBookingsByCurrency(), - this.overviewRepository.getRecentBookings(8), + this.overviewRepository.getBookingKpis(dirs), + this.overviewRepository.getBookingTrend(days, dirs), + this.overviewRepository.getStatusCounts(dirs), + this.overviewRepository.getBookingsByFreightType(dirs), + this.overviewRepository.getBookingsByCurrency(dirs), + this.overviewRepository.getRecentBookings(8, dirs), ]); const { bookingsByPipeline, bookingsByStatus } = @@ -130,6 +136,7 @@ export class OverviewService { async getContractsTab( range: OverviewRangeQuery = '30d', + dirs?: string[], ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; @@ -141,12 +148,12 @@ export class OverviewService { contractsByFreightType, recentContracts, ] = await Promise.all([ - this.overviewRepository.getContractKpis(), - this.overviewRepository.getContractTrend(days), - this.overviewRepository.getContractStatusCounts(), - this.overviewRepository.getContractsByKind(), - this.overviewRepository.getContractsByFreightType(), - this.overviewRepository.getRecentContracts(8), + this.overviewRepository.getContractKpis(dirs), + this.overviewRepository.getContractTrend(days, dirs), + this.overviewRepository.getContractStatusCounts(dirs), + this.overviewRepository.getContractsByKind(dirs), + this.overviewRepository.getContractsByFreightType(dirs), + this.overviewRepository.getRecentContracts(8, dirs), ]); const contractsByStatus = Object.entries(statusCounts) @@ -170,16 +177,19 @@ export class OverviewService { }; } - async getBillingTab(range: OverviewRangeQuery = '30d'): Promise { + async getBillingTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [kpis, paymentTrend, paymentsByStatus, paymentsByMethod, revenueByCurrency] = await Promise.all([ - this.overviewRepository.getBillingKpis(), - this.overviewRepository.getPaymentTrend(days), - this.overviewRepository.getPaymentsByStatus(), - this.overviewRepository.getPaymentsByMethod(), - this.overviewRepository.getRevenueByCurrency(), + this.overviewRepository.getBillingKpis(dirs), + this.overviewRepository.getPaymentTrend(days, dirs), + this.overviewRepository.getPaymentsByStatus(dirs), + this.overviewRepository.getPaymentsByMethod(dirs), + this.overviewRepository.getRevenueByCurrency(dirs), ]); return { @@ -217,7 +227,10 @@ export class OverviewService { }; } - async getCustomersTab(range: OverviewRangeQuery = '30d'): Promise { + async getCustomersTab( + range: OverviewRangeQuery = '30d', + dirs?: string[], + ): Promise { const days = OVERVIEW_RANGE_DAYS[range]; const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] = @@ -225,7 +238,7 @@ export class OverviewService { this.overviewRepository.getCustomerKpis(), this.overviewRepository.getCustomerGrowthTrend(days), this.overviewRepository.getCustomersByType(), - this.overviewRepository.getTopCustomersByBookings(8), + this.overviewRepository.getTopCustomersByBookings(8, dirs), ]); return { diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 2ae62d4f3..3602657de 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -15,8 +15,10 @@ import { ApiProduces, } from "@nestjs/swagger"; import { Response } from "express"; -import { Public } from "@edr/api-common"; +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 { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { PaymentService } from "./payment.service"; import { IntentStatusDto } from "./payments.dto"; @@ -24,7 +26,10 @@ import { IntentStatusDto } from "./payments.dto"; @ApiTags("Payment") @Controller("payments") export class PaymentController { - constructor(private readonly paymentService: PaymentService) { } + constructor( + private readonly paymentService: PaymentService, + private readonly userTradeAccessService: UserTradeAccessService, + ) { } // Customer-detail payments tab — same one-of rule as the bookings tab. @Get("by-company/:companyId/customer-view") @@ -52,18 +57,23 @@ export class PaymentController { @ApiQuery({ name: "page", required: false }) @ApiQuery({ name: "pageSize", required: false }) async getAll( + @CurrentUser() user: TCurrentUser, @Query("search") search?: string, @Query("status") status?: string, @Query("method") method?: string, @Query("page") page?: string, @Query("pageSize") pageSize?: string, ) { + // Per-user trade-direction scope, applied via the booking in ref_id. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); return this.paymentService.getAll({ search, status, method, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 10, + tradeDirections: allowed ?? undefined, }); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 961aa32bd..220250d93 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -13,6 +13,7 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; +import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; // import { FirstMileModule } from "../first-mile/first-mile.module"; // import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; @@ -64,6 +65,7 @@ function rabbitMQImport(): DynamicModule[] { timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000, }), ConfigModule, + UserTradeAccessModule, forwardRef(() => BillingModule), // forwardRef(() => TrainSchedulingModule), // FirstMileModule, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 0f628e39c..57e0de603 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -7,6 +7,7 @@ import { Logger, NotFoundException, } from "@nestjs/common"; +import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; @@ -110,6 +111,8 @@ export class PaymentService { method?: string; page?: number; pageSize?: number; + /** Per-user trade-direction scope, applied via the booking in ref_id. */ + tradeDirections?: string[]; }) { const { search, status, method, page = 1, pageSize = 10 } = filters; const skip = (page - 1) * pageSize; @@ -128,6 +131,9 @@ export class PaymentService { if (method) { qb.andWhere("payment.method = :method", { method }); } + if (filters.tradeDirections) { + applyBookingRefDirectionScope(qb, "payment.ref_id", filters.tradeDirections); + } const [items, total] = await qb .orderBy("payment.createdAt", "DESC") diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts index 5712eed03..9c69f0135 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -37,6 +37,27 @@ export class YardFacility extends BaseEntity { @Column({ name: 'handles_bulk', type: 'boolean', default: true }) handlesBulk!: boolean; + /** + * Per-side capability. Loading a type onto a train and receiving it off one + * need different ground: a facility can be equipped to send containers but + * have no space to stage arriving ones. `handles_container` / `handles_bulk` + * stay the coarse "is this type handled here at all" switch; these four say + * on which side. A yard is offered as a contract origin for a freight type + * when it handles the type AND the matching `_origin` flag is set, and as a + * destination on the same rule with `_destination`. + */ + @Column({ name: 'has_container_facility_origin', type: 'boolean', default: false }) + hasContainerFacilityOrigin!: boolean; + + @Column({ name: 'has_bulk_facility_origin', type: 'boolean', default: false }) + hasBulkFacilityOrigin!: boolean; + + @Column({ name: 'has_container_facility_destination', type: 'boolean', default: false }) + hasContainerFacilityDestination!: boolean; + + @Column({ name: 'has_bulk_facility_destination', type: 'boolean', default: false }) + hasBulkFacilityDestination!: boolean; + @Column({ name: 'equipment_notes', type: 'text', nullable: true }) equipmentNotes?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts index 4b69c328f..2579c8d22 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -13,8 +13,20 @@ export interface YardFacilityInfo { /** Containers need a reach stacker/gantry — not every facility has one. */ handlesContainer: boolean; handlesBulk: boolean; + /** + * The same capability split by side of the trip — loading onto a train and + * receiving off one need different ground. Always false where the coarse + * `handles*` flag for that type is false. + */ + hasContainerFacilityOrigin: boolean; + hasBulkFacilityOrigin: boolean; + hasContainerFacilityDestination: boolean; + hasBulkFacilityDestination: boolean; } +/** Which side of the trip a yard is being considered for. */ +export type YardSide = 'ORIGIN' | 'DESTINATION'; + /** * Which yards can handle cargo, and what kind. * @@ -38,7 +50,11 @@ export class YardFacilitiesService { y.has_facility AS "hasFacility", f.has_warehouse AS "hasWarehouse", f.handles_container AS "handlesContainer", - f.handles_bulk AS "handlesBulk" + f.handles_bulk AS "handlesBulk", + f.has_container_facility_origin AS "hasContainerFacilityOrigin", + f.has_bulk_facility_origin AS "hasBulkFacilityOrigin", + f.has_container_facility_destination AS "hasContainerFacilityDestination", + f.has_bulk_facility_destination AS "hasBulkFacilityDestination" FROM freight.yards y LEFT JOIN freight.yard_facilities f ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`; @@ -51,17 +67,33 @@ export class YardFacilitiesService { hasWarehouse: boolean | null; handlesContainer: boolean | null; handlesBulk: boolean | null; + hasContainerFacilityOrigin: boolean | null; + hasBulkFacilityOrigin: boolean | null; + hasContainerFacilityDestination: boolean | null; + hasBulkFacilityDestination: boolean | null; }): YardFacilityInfo { // No facility record means no capability, whatever the flag says. const hasFacility = Boolean(row.hasFacility); + const handlesContainer = hasFacility && Boolean(row.handlesContainer); + const handlesBulk = hasFacility && Boolean(row.handlesBulk); return { yardId: row.yardId, yardCode: row.yardCode, yardLabel: row.yardLabel, hasFacility, hasWarehouse: hasFacility && Boolean(row.hasWarehouse), - handlesContainer: hasFacility && Boolean(row.handlesContainer), - handlesBulk: hasFacility && Boolean(row.handlesBulk), + handlesContainer, + handlesBulk, + // Gated on the coarse flag so the two can't contradict each other: a + // per-side flag left set on a type the facility no longer handles at all + // never resurrects that type. + hasContainerFacilityOrigin: + handlesContainer && Boolean(row.hasContainerFacilityOrigin), + hasBulkFacilityOrigin: handlesBulk && Boolean(row.hasBulkFacilityOrigin), + hasContainerFacilityDestination: + handlesContainer && Boolean(row.hasContainerFacilityDestination), + hasBulkFacilityDestination: + handlesBulk && Boolean(row.hasBulkFacilityDestination), }; } @@ -97,4 +129,26 @@ export class YardFacilitiesService { ? facility.handlesContainer : facility.handlesBulk; } + + /** + * Can this facility take this cargo on this side of the trip? The rule behind + * the contract's origin/destination yard pickers — keep it here so the API + * and the forms can't drift apart on what is offerable. + */ + canHandleFreightOnSide( + facility: YardFacilityInfo | null, + freightType: string | null | undefined, + side: YardSide, + ): boolean { + if (!facility?.hasFacility) return false; + const isContainer = String(freightType).toUpperCase() === 'CONTAINER'; + if (side === 'ORIGIN') { + return isContainer + ? facility.hasContainerFacilityOrigin + : facility.hasBulkFacilityOrigin; + } + return isContainer + ? facility.hasContainerFacilityDestination + : facility.hasBulkFacilityDestination; + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 3d3b992d3..efdf4f691 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1437,6 +1437,7 @@ export class BookingBatchService implements OnModuleInit { */ async getBatchBoard( query: BatchBoardQueryDto = {}, + allowedDirections?: string[], ): Promise { // Board cards are heavy (per-schedule booking summaries), so the default // page is smaller than the toolkit-wide 20. @@ -1444,6 +1445,11 @@ export class BookingBatchService implements OnModuleInit { defaultPageSize: 12, }); + // The board is IMPORT-only — a user scoped away from IMPORT sees nothing. + if (allowedDirections && !allowedDirections.includes("IMPORT")) { + return { items: [], meta: buildPaginationMeta(0, page, pageSize) }; + } + // Status filter: any subset of the lifecycle. Omitted = all statuses, so // arrived / cancelled / dispatched schedules stay visible as history. const allowedStatuses = new Set(BATCH_BOARD_STATUSES); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 7b0f37b66..485a3ceea 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -1,6 +1,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; +import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; import { @@ -66,6 +67,7 @@ export class TrainSchedulingController { private readonly intercityService: IntercityService, private readonly bookingJourneyService: BookingJourneyService, private readonly billingService: BillingService, + private readonly userTradeAccessService: UserTradeAccessService, ) { } @Get("my-booking-windows") @@ -130,8 +132,14 @@ export class TrainSchedulingController { summary: "Batch monitoring board: paginated import schedules (all statuses) with bookings grouped by state", }) - getBatchBoard(@Query() query: BatchBoardQueryDto) { - return this.bookingBatchService.getBatchBoard(query); + async getBatchBoard( + @Query() query: BatchBoardQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + // Batch board is IMPORT-only — a user without IMPORT access sees nothing. + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.bookingBatchService.getBatchBoard(query, allowed ?? undefined); } @Get("batch-board/:scheduleId") @@ -868,15 +876,31 @@ export class TrainSchedulingController { @Get("container/schedules") @TrainSchedulingView() @ApiOperation({ summary: "List container train schedules (paginated)" }) - getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) { - return this.trainSchedulingService.getContainerTrainSchedules(query); + async getContainerTrainSchedules( + @Query() query: ListTrainSchedulesQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.trainSchedulingService.getContainerTrainSchedules( + query, + allowed ?? undefined, + ); } @Get("bulk/schedules") @TrainSchedulingView() @ApiOperation({ summary: "List bulk train schedules (paginated)" }) - getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) { - return this.trainSchedulingService.getContainerTrainSchedules(query); + async getBulkTrainSchedules( + @Query() query: ListTrainSchedulesQueryDto, + @CurrentUser() user: AuthUserPayload, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.trainSchedulingService.getContainerTrainSchedules( + query, + allowed ?? undefined, + ); } @Get("container/schedules/:id") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 35503d432..56f2cff00 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity'; import { BillingModule } from '../billing/billing.module'; +import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; @@ -63,6 +64,7 @@ import { ContractsModule } from '../contracts/contracts.module'; ]), forwardRef(() => BookingsModule), BillingModule, + UserTradeAccessModule, NotificationsModule, NotificationInboxModule, LocomotivesModule, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 9c60c9821..495feedf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3899,13 +3899,25 @@ export class TrainSchedulingService { return Object.assign(detail, { warehouseAutomation }); } - async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) { + async getContainerTrainSchedules( + query: ListTrainSchedulesQueryDto = {}, + allowedDirections?: string[], + ) { const { page, pageSize, skip, take } = normalizePagination(query); + // Per-user trade-direction scope: schedules carry a `direction` column. + if (allowedDirections && allowedDirections.length === 0) { + return { + items: [], + meta: buildPaginationMeta(0, page, pageSize), + }; + } + // Exact-match filters (enum/id semantics). Freight type is derived from // the bookings aboard — no column to match — so it rides on `id` as an // EXISTS fragment instead. const base: FindOptionsWhere = {}; + if (allowedDirections) base.direction = In(allowedDirections) as never; if (query.status) base.status = query.status; if (query.originStationId) base.originStationId = query.originStationId; if (query.destinationStationId) base.destinationStationId = query.destinationStationId; diff --git a/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts b/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts new file mode 100644 index 000000000..1c2db098e --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/dto/upsert-user-trade-access.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayUnique, IsIn } from 'class-validator'; +import { Freight } from '@edr/types'; + +export class UpsertUserTradeAccessDto { + @ApiProperty({ + description: + 'Trade directions the user may see. All three (or no config row) = unrestricted; empty array = sees nothing.', + isArray: true, + enum: ['IMPORT', 'EXPORT', 'DOMESTIC'], + example: ['IMPORT', 'DOMESTIC'], + }) + @ArrayUnique() + @IsIn(['IMPORT', 'EXPORT', 'DOMESTIC'], { each: true }) + directions!: Freight.ScheduleTradeDirection[]; +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts b/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts new file mode 100644 index 000000000..6ef5ca1fb --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/entities/user-trade-access.entity.ts @@ -0,0 +1,27 @@ +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * Which trade directions (IMPORT / EXPORT / DOMESTIC=Intercity) a backoffice + * user may see. No row, or all three directions, means unrestricted. + */ +@Entity({ schema: 'freight', name: 'user_trade_access' }) +export class UserTradeAccess extends BaseEntity { + /** IAM user id (iam.users) — no FK, iam schema is externally owned. */ + @Index() + @Column({ name: 'user_id', type: 'uuid', unique: true }) + userId!: string; + + @Column({ name: 'directions', type: 'text', default: '' }) + directionsRaw!: string; + + @Column({ name: 'updated_by_id', type: 'uuid', nullable: true }) + updatedById!: string | null; + + get directions(): Freight.ScheduleTradeDirection[] { + return this.directionsRaw + ? (this.directionsRaw.split(',') as Freight.ScheduleTradeDirection[]) + : []; + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts new file mode 100644 index 000000000..37ffa93c9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.ts @@ -0,0 +1,100 @@ +import { Freight } from '@edr/types'; +import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; + +/** + * Resolve the effective direction list for a query. + * + * @param allowed the user's scope — null = unrestricted + * @param requested an explicit ?tradeDirection=… filter, if any + * @returns directions to filter by, `null` = no filter, `[]` = show nothing + */ +export function scopedDirections( + allowed: Freight.ScheduleTradeDirection[] | null, + requested?: string | null, +): string[] | null { + if (!allowed) return requested ? [requested] : null; + if (!requested) return [...allowed]; + return allowed.includes(requested as Freight.ScheduleTradeDirection) + ? [requested] + : []; +} + +/** + * Apply a direction scope to a query builder column. + * `dirs = null` → untouched; `dirs = []` → matches nothing. + */ +export function applyDirectionScope( + qb: T, + column: string, + dirs: string[] | null, +): T { + if (dirs === null) return qb; + if (dirs.length === 0) { + qb.andWhere('1 = 0'); + return qb; + } + // Unique param name so multiple scopes can coexist on one query. + const param = `scopeDirs_${column.replace(/\W/g, '_')}`; + qb.andWhere(`${column} IN (:...${param})`, { [param]: dirs }); + return qb; +} + +/** + * SQL-fragment form of {@link applyDirectionScope} for fluent query chains: + * `.andWhere(f.sql, f.params)`. `dirs = null/undefined` → TRUE (no-op). + */ +export function directionScopeSql( + column: string, + dirs: string[] | null | undefined, +): { sql: string; params: Record } { + if (!dirs) return { sql: 'TRUE', params: {} }; + if (dirs.length === 0) return { sql: 'FALSE', params: {} }; + const param = `scopeDirs_${column.replace(/\W/g, '_')}`; + return { sql: `${column} IN (:...${param})`, params: { [param]: dirs } }; +} + +/** + * SQL-fragment form of {@link applyBookingRefDirectionScope}: hides rows whose + * varchar ref column points at a booking outside the scope; rows that do not + * point at a booking stay visible (they carry no direction to scope by). + */ +export function bookingRefScopeSql( + refColumn: string, + dirs: string[] | null | undefined, +): { sql: string; params: Record } { + if (!dirs) return { sql: 'TRUE', params: {} }; + const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`; + const disallowed = dirs.length + ? `b.trade_direction NOT IN (:...${param})` + : 'TRUE'; + return { + sql: `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`, + params: dirs.length ? { [param]: dirs } : {}, + }; +} + +/** + * Scope rows whose direction lives on a related booking referenced by a + * varchar id column (invoices.source_id, payments.ref_id). Rows that do not + * point at a booking stay visible — they carry no direction to scope by. + */ +export function applyBookingRefDirectionScope( + qb: SelectQueryBuilder, + refColumn: string, + dirs: string[] | null, +): SelectQueryBuilder { + if (dirs === null) return qb; + const param = `scopeRefDirs_${refColumn.replace(/\W/g, '_')}`; + const disallowed = dirs.length + ? `b.trade_direction NOT IN (:...${param})` + : 'TRUE'; + qb.andWhere( + new Brackets((w) => { + w.where( + `NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id::text = ${refColumn} AND ${disallowed})`, + ); + }), + ); + if (dirs.length) qb.setParameter(param, dirs); + return qb; +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts new file mode 100644 index 000000000..3c6255fd9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + ParseUUIDPipe, + Put, +} from '@nestjs/common'; +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 { isFreightApprovalAdmin } from '../../common/freight-permission.util'; +import { UpsertUserTradeAccessDto } from './dto/upsert-user-trade-access.dto'; +import { UserTradeAccessService } from './user-trade-access.service'; + +@ApiTags('user-trade-access') +@Controller('user-trade-access') +@StaffReference() +@ApiBearerAuth() +export class UserTradeAccessController { + constructor(private readonly service: UserTradeAccessService) {} + + @Get() + @ApiOperation({ summary: 'List every configured user trade-direction scope' }) + list(@CurrentUser() user: TCurrentUser) { + this.assertAdmin(user); + return this.service.listConfigs(); + } + + @Get('me') + @ApiOperation({ summary: "Current user's effective trade-direction scope" }) + async me(@CurrentUser() user: TCurrentUser) { + const allowed = await this.service.resolveAllowedDirections(user); + return { + restricted: allowed !== null, + directions: allowed ?? ['IMPORT', 'EXPORT', 'DOMESTIC'], + }; + } + + @Put(':userId') + @ApiOperation({ + summary: 'Set the trade directions a backoffice user may see', + }) + upsert( + @Param('userId', ParseUUIDPipe) userId: string, + @Body() dto: UpsertUserTradeAccessDto, + @CurrentUser() user: TCurrentUser, + ) { + this.assertAdmin(user); + return this.service.upsert( + userId, + dto.directions, + (user as { id?: string } | null)?.id ?? null, + ); + } + + private assertAdmin(user: TCurrentUser) { + if (!isFreightApprovalAdmin(user)) { + throw new ForbiddenException( + 'Only super or organization admins can manage trade-direction access', + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts new file mode 100644 index 000000000..8b3515930 --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { UserTradeAccess } from './entities/user-trade-access.entity'; +import { UserTradeAccessController } from './user-trade-access.controller'; +import { UserTradeAccessRepository } from './user-trade-access.repository'; +import { UserTradeAccessService } from './user-trade-access.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserTradeAccess])], + controllers: [UserTradeAccessController], + providers: [UserTradeAccessService, UserTradeAccessRepository], + exports: [UserTradeAccessService], +}) +export class UserTradeAccessModule {} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts new file mode 100644 index 000000000..1df85b5ee --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.repository.ts @@ -0,0 +1,23 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { UserTradeAccess } from './entities/user-trade-access.entity'; + +@Injectable() +export class UserTradeAccessRepository extends BaseRepository { + constructor( + @InjectRepository(UserTradeAccess) repository: Repository, + ) { + super(repository); + } + + findByUserId(userId: string): Promise { + return this.repository.findOne({ where: { userId } }); + } + + findAllConfigs(): Promise { + return this.repository.find({ order: { updatedAt: 'DESC' } }); + } +} diff --git a/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts new file mode 100644 index 000000000..bcabdea8b --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/user-trade-access.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { Freight } from '@edr/types'; +import { isFreightApprovalAdmin } from '../../common/freight-permission.util'; +import { UserTradeAccess } from './entities/user-trade-access.entity'; +import { UserTradeAccessRepository } from './user-trade-access.repository'; + +const ALL: Freight.ScheduleTradeDirection[] = ['IMPORT', 'EXPORT', 'DOMESTIC']; + +/** Loose current-user shape: JWT payloads and TCurrentUser both fit. */ +export type ScopeUser = + | ({ id?: string; sub?: string; roles?: { key?: string }[] } & object) + | null + | undefined; + +export type UserTradeAccessView = { + userId: string; + directions: Freight.ScheduleTradeDirection[]; + updatedAt: Date; +}; + +@Injectable() +export class UserTradeAccessService { + constructor(private readonly repository: UserTradeAccessRepository) {} + + async listConfigs(): Promise { + const rows = await this.repository.findAllConfigs(); + return rows.map((r) => this.toView(r)); + } + + async upsert( + userId: string, + directions: Freight.ScheduleTradeDirection[], + actorId?: string | null, + ): Promise { + // Normalize to canonical order so "all three" compares reliably. + const normalized = ALL.filter((d) => directions.includes(d)); + const existing = await this.repository.findByUserId(userId); + const saved = existing + ? await this.repository.update(existing.id, { + directionsRaw: normalized.join(','), + updatedById: actorId ?? null, + }) + : await this.repository.create({ + userId, + directionsRaw: normalized.join(','), + updatedById: actorId ?? null, + }); + return this.toView(saved as UserTradeAccess); + } + + /** + * Effective scope for the current user. + * `null` = unrestricted (no config, all three directions, admin, or no user + * on the request — routes without auth cannot be scoped). + */ + async resolveAllowedDirections( + user: ScopeUser, + ): Promise { + const userId = user?.id ?? user?.sub; + if (!userId) return null; + if (isFreightApprovalAdmin(user)) return null; + const row = await this.repository.findByUserId(userId); + if (!row) return null; + const dirs = row.directions; + if (dirs.length >= ALL.length) return null; + return dirs; + } + + private toView(row: UserTradeAccess): UserTradeAccessView { + return { + userId: row.userId, + directions: row.directions, + updatedAt: row.updatedAt, + }; + } +} diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts index 1822a877f..fafbd3a9a 100644 --- a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -59,14 +59,23 @@ export class YardFacilitiesSeeder { [yard.id], ); + // Every facility works both sides of the trip today, so the per-side + // flags mirror the freight-type flags. Narrow an individual yard here + // when a real one turns out to load a type but not receive it. await this.dataSource.query( `INSERT INTO freight.yard_facilities - (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes) - VALUES ($1, $2, $3, true, $4) + (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes, + has_container_facility_origin, has_container_facility_destination, + has_bulk_facility_origin, has_bulk_facility_destination) + VALUES ($1, $2, $3, true, $4, $3, $3, true, true) ON CONFLICT (yard_id) WHERE deleted_at IS NULL DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, handles_container = EXCLUDED.handles_container, handles_bulk = EXCLUDED.handles_bulk, + has_container_facility_origin = EXCLUDED.has_container_facility_origin, + has_container_facility_destination = EXCLUDED.has_container_facility_destination, + has_bulk_facility_origin = EXCLUDED.has_bulk_facility_origin, + has_bulk_facility_destination = EXCLUDED.has_bulk_facility_destination, updated_at = NOW()`, [yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`], ); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d523d2e8a..ceb2a7d06 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -111,6 +111,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; @@ -585,6 +586,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/configuration/train-scheduling-rules", permission: FREIGHT_PERMS.trainScheduling.rulesManage, }, + { + label: "Trade access", + href: "/dashboard/configuration/trade-access", + permission: FREIGHT_PERMS.admin, + }, ], }, { @@ -1549,6 +1555,14 @@ const App = () => { } /> + + + + } + /> {/* { + o.userData.locoId = locoId; + }); + pickables.push(g); return g; } @@ -457,6 +466,119 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3 return g; } +function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) { + if (value === null || value === undefined || value === "") return null; + return ( + + {label} + {value} + + ); +} + +function LocoDetailPanel({ + locoId, + schedule, + onClose, + onDrive, +}: { + locoId: string; + schedule: TrainScheduleDetail; + onClose: () => void; + onDrive: () => void; +}) { + const locos = schedule.trainSet?.locomotives?.length + ? schedule.trainSet.locomotives + : schedule.trainSet?.locomotive + ? [schedule.trainSet.locomotive] + : []; + const loco = locos.find((l) => l.id === locoId || l.code === locoId); + const ts = schedule.trainSet; + return ( + + + Locomotive {loco?.code ?? ""} + + + + + + {loco ? ( + <> + {loco.name ? {loco.name} : null} + + {loco.status} + + + {loco.maxTrainLengthMeters ? ( + + ) : null} + + ) : ( + No locomotive assigned yet. + )} + + + + + + + + + + + {ts ? ( + <> + + + + + {ts.heaviestLeg ? ( + + ) : null} + + ) : null} + + + + ); +} + function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) { const bookings = schedule.bookings ?? []; return ( @@ -547,6 +669,8 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization const containerRef = useRef(null); const canvasHostRef = useRef(null); const [selectedWagonId, setSelectedWagonId] = useState(null); + const [selectedLocoId, setSelectedLocoId] = useState(null); + const [driveMode, setDriveMode] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const cameraApiRef = useRef<{ overview: () => void; @@ -556,7 +680,9 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization } | null>(null); const wagons = schedule.trainSet?.wagons ?? []; - const moving = String(schedule.status).toUpperCase() === "DISPATCHED"; + const dispatched = String(schedule.status).toUpperCase() === "DISPATCHED"; + // drive mode always simulates motion, even before dispatch + const moving = dispatched || driveMode; const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null; const orderedWagons = [...wagons].sort( (a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo), @@ -572,10 +698,16 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization : orderedWagons.length - 1 : (selectedIndex + dir + orderedWagons.length) % orderedWagons.length; const wagon = orderedWagons[next]; + setSelectedLocoId(null); setSelectedWagonId(wagon.id); cameraApiRef.current?.focusWagon(wagon.id); }; + const firstLocoId = + schedule.trainSet?.locomotives?.[0]?.id ?? + schedule.trainSet?.locomotive?.id ?? + "LOCO"; + useEffect(() => { const host = canvasHostRef.current; if (!host) return; @@ -619,11 +751,13 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization : schedule.trainSet?.locomotive ? [schedule.trainSet.locomotive] : [{ code: "LOCO" }]; + const locoGroups: Array<{ id: string; group: THREE.Group }> = []; for (const loco of locos) { - const lg = buildLocomotive(loco, wheels); + const lg = buildLocomotive(loco, wheels, pickables); lg.position.x = cursor - lg.userData.length / 2; cursor -= lg.userData.length + gap; train.add(lg); + locoGroups.push({ id: lg.userData.locoId as string, group: lg }); } const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo)); const wagonGroups: Array<{ id: string; group: THREE.Group }> = []; @@ -650,6 +784,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization controls.maxDistance = Math.max(600, trainLen * 1.6); controls.enableDamping = true; controls.zoomToCursor = true; // wheel zooms toward the point under the pointer + controls.enabled = !driveMode; // cab ride owns the camera // camera fly-to: goal recomputed each frame so it tracks a moving train let cameraGoal: (() => { pos: THREE.Vector3; target: THREE.Vector3 }) | null = null; @@ -692,6 +827,20 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization controls.addEventListener("start", () => { cameraGoal = null; }); + if (driveMode) { + // cab ride: camera on the driver's seat of the lead loco, looking down the line + const locoLen = frontGroup.userData.length as number; + cameraGoal = () => { + frontGroup.getWorldPosition(worldPos); + return { + pos: new THREE.Vector3(worldPos.x + locoLen / 2 - 3.2, DECK_H + 3.9, 1.05), + target: new THREE.Vector3(worldPos.x + locoLen / 2 + 140, 2.2, 0), + }; + }; + } else if (moving) { + // dispatched → open following the rolling train so motion is obvious + cameraApiRef.current.overview(); + } // picking const raycaster = new THREE.Raycaster(); @@ -714,11 +863,21 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization raycaster.setFromCamera(pointer, camera); const hits = raycaster.intersectObjects(pickables, true); const wagonId = hits[0]?.object.userData.wagonId as string | undefined; + const locoId = hits[0]?.object.userData.locoId as string | undefined; if (highlighted) setEmissive(highlighted, false); - highlighted = wagonId ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) : null; + highlighted = wagonId + ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) + : locoId + ? (locoGroups.find((l) => l.id === locoId)?.group ?? null) + : null; if (highlighted) setEmissive(highlighted, true); setSelectedWagonId(wagonId ?? null); + setSelectedLocoId(locoId && !wagonId ? locoId : null); if (wagonId) cameraApiRef.current?.focusWagon(wagonId); + else if (locoId) { + const lg = locoGroups.find((l) => l.id === locoId); + if (lg) flyToObject(lg.group, 30, 9); + } }; renderer.domElement.addEventListener("click", onClick); @@ -732,20 +891,27 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization let raf = 0; let last = performance.now(); - const speed = 8; // m/s visual speed when dispatched + let elapsed = 0; + const speed = 14; // m/s visual speed when dispatched const animate = () => { raf = requestAnimationFrame(animate); const now = performance.now(); const dt = Math.min((now - last) / 1000, 0.1); last = now; if (moving) { + elapsed += dt; train.position.x += speed * dt; for (const w of wheels) w.rotation.y += (speed * dt) / WHEEL_R; - if (train.position.x > trainLen / 2 + 120) train.position.x = trainLen / 2 - 120; + // subtle rail-joint sway so motion reads even up close + train.position.y = Math.sin(elapsed * 9) * 0.03; + train.rotation.z = Math.sin(elapsed * 4.5) * 0.0025; + // loop over the long track stretch; jump happens far off-screen + const half = finalTrackLen / 2 - trainLen - 40; + if (train.position.x > half + trainLen) train.position.x = -half; } if (cameraGoal) { const goal = cameraGoal(); - const k = Math.min(1, dt * 3.2); + const k = Math.min(1, dt * (driveMode ? 7 : 3.2)); camera.position.lerp(goal.pos, k); controls.target.lerp(goal.target, k); // static scene: release goal once settled so orbiting feels free again @@ -772,12 +938,14 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization }); }; // rebuild scene only when schedule identity/status changes - }, [schedule.id, schedule.status, moving, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps + }, [schedule.id, schedule.status, moving, driveMode, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape" && !document.fullscreenElement) onClose(); + if (e.key !== "Escape" || document.fullscreenElement) return; + if (driveMode) setDriveMode(false); + else onClose(); }; document.addEventListener("fullscreenchange", onFsChange); window.addEventListener("keydown", onKey); @@ -785,7 +953,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization document.removeEventListener("fullscreenchange", onFsChange); window.removeEventListener("keydown", onKey); }; - }, [onClose]); + }, [onClose, driveMode]); const toggleFullscreen = () => { if (document.fullscreenElement) void document.exitFullscreen(); @@ -807,8 +975,12 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization gap="xs" style={{ position: "absolute", top: 16, left: 16, zIndex: 10 }} > - - {moving ? "DISPATCHED — TRAIN IN MOTION" : `${schedule.status} — TRAIN STOPPED`} + + {driveMode + ? "DRIVER VIEW — SIMULATION" + : dispatched + ? "DISPATCHED — TRAIN IN MOTION" + : `${schedule.status} — TRAIN STOPPED`} {schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons @@ -839,13 +1011,26 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization size="compact-sm" variant="default" leftSection={} - onClick={() => cameraApiRef.current?.front()} + onClick={() => { + setSelectedWagonId(null); + setSelectedLocoId(firstLocoId); + cameraApiRef.current?.front(); + }} > Locomotive +