mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 16:00:56 +00:00
per-user trade-direction access scope
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<BookingReferenceDataDto> {
|
||||
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 => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Booking> {
|
||||
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,
|
||||
|
||||
@@ -1619,6 +1619,7 @@ export class BookingsService {
|
||||
filter: FilterBookingDto,
|
||||
forceCompanyId?: string,
|
||||
forceCompanyProfileId?: string,
|
||||
tradeDirections?: string[],
|
||||
): Promise<PaginatedBookings> {
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Contract> {
|
||||
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,
|
||||
|
||||
@@ -748,6 +748,7 @@ export class ContractsService {
|
||||
filter: FilterContractDto,
|
||||
forceCompanyId?: string,
|
||||
forceCompanyProfileId?: string,
|
||||
tradeDirections?: string[],
|
||||
): Promise<PaginatedContracts> {
|
||||
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,
|
||||
|
||||
@@ -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<OverviewResponseDto> {
|
||||
return this.overviewService.getDashboard(query.range ?? '30d');
|
||||
async getDashboard(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewResponseDto> {
|
||||
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<OverviewBookingsTabDto> {
|
||||
return this.overviewService.getBookingsTab(query.range ?? '30d');
|
||||
async getBookingsTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewBookingsTabDto> {
|
||||
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<OverviewContractsTabDto> {
|
||||
return this.overviewService.getContractsTab(query.range ?? '30d');
|
||||
async getContractsTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewContractsTabDto> {
|
||||
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<OverviewBillingTabDto> {
|
||||
return this.overviewService.getBillingTab(query.range ?? '30d');
|
||||
async getBillingTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewBillingTabDto> {
|
||||
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<OverviewCustomersTabDto> {
|
||||
return this.overviewService.getCustomersTab(query.range ?? '30d');
|
||||
async getCustomersTab(
|
||||
@Query() query: OverviewQueryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
): Promise<OverviewCustomersTabDto> {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.overviewService.getCustomersTab(
|
||||
query.range ?? '30d',
|
||||
allowed ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('staff')
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<User>,
|
||||
) { }
|
||||
|
||||
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||
async getBookingKpis(dirs?: string[]): Promise<OverviewBookingKpisRow> {
|
||||
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<Record<string, string>>();
|
||||
|
||||
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<Record<string, number>> {
|
||||
async getStatusCounts(dirs?: string[]): Promise<Record<string, number>> {
|
||||
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<OverviewRecentBookingRow[]> {
|
||||
async getRecentBookings(
|
||||
limit: number,
|
||||
dirs?: string[],
|
||||
): Promise<OverviewRecentBookingRow[]> {
|
||||
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<OverviewContractKpisRow> {
|
||||
async getContractKpis(dirs?: string[]): Promise<OverviewContractKpisRow> {
|
||||
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<Record<string, number>> {
|
||||
async getContractStatusCounts(
|
||||
dirs?: string[],
|
||||
): Promise<Record<string, number>> {
|
||||
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<OverviewRecentContractRow[]> {
|
||||
async getRecentContracts(
|
||||
limit: number,
|
||||
dirs?: string[],
|
||||
): Promise<OverviewRecentContractRow[]> {
|
||||
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<{
|
||||
|
||||
@@ -40,7 +40,10 @@ export class OverviewService {
|
||||
return { bookingsByPipeline, bookingsByStatus };
|
||||
}
|
||||
|
||||
async getDashboard(range: OverviewRangeQuery = '30d'): Promise<OverviewResponseDto> {
|
||||
async getDashboard(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewResponseDto> {
|
||||
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<OverviewBookingsTabDto> {
|
||||
async getBookingsTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewBookingsTabDto> {
|
||||
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<OverviewContractsTabDto> {
|
||||
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<OverviewBillingTabDto> {
|
||||
async getBillingTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewBillingTabDto> {
|
||||
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<OverviewCustomersTabDto> {
|
||||
async getCustomersTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
dirs?: string[],
|
||||
): Promise<OverviewCustomersTabDto> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1437,6 +1437,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
async getBatchBoard(
|
||||
query: BatchBoardQueryDto = {},
|
||||
allowedDirections?: string[],
|
||||
): Promise<BatchBoardListResponse> {
|
||||
// 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<string>(BATCH_BOARD_STATUSES);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TrainSchedule> = {};
|
||||
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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[])
|
||||
: [];
|
||||
}
|
||||
}
|
||||
@@ -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<T extends WhereExpressionBuilder>(
|
||||
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<string, unknown> } {
|
||||
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<string, unknown> } {
|
||||
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<T>(
|
||||
qb: SelectQueryBuilder<T & object>,
|
||||
refColumn: string,
|
||||
dirs: string[] | null,
|
||||
): SelectQueryBuilder<T & object> {
|
||||
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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<UserTradeAccess> {
|
||||
constructor(
|
||||
@InjectRepository(UserTradeAccess) repository: Repository<UserTradeAccess>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByUserId(userId: string): Promise<UserTradeAccess | null> {
|
||||
return this.repository.findOne({ where: { userId } });
|
||||
}
|
||||
|
||||
findAllConfigs(): Promise<UserTradeAccess[]> {
|
||||
return this.repository.find({ order: { updatedAt: 'DESC' } });
|
||||
}
|
||||
}
|
||||
@@ -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<UserTradeAccessView[]> {
|
||||
const rows = await this.repository.findAllConfigs();
|
||||
return rows.map((r) => this.toView(r));
|
||||
}
|
||||
|
||||
async upsert(
|
||||
userId: string,
|
||||
directions: Freight.ScheduleTradeDirection[],
|
||||
actorId?: string | null,
|
||||
): Promise<UserTradeAccessView> {
|
||||
// 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<Freight.ScheduleTradeDirection[] | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user