This commit is contained in:
Marshal
2026-07-15 13:29:01 +00:00
parent c71a0043d6
commit 19c9da28ae
59 changed files with 3008 additions and 284 deletions

View File

@@ -0,0 +1,72 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, 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 { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import {
DecidePriorityRuleChangeDto,
SubmitPriorityRuleChangeDto,
} from '../dto/priority-rule-change-request.dto';
import { PriorityRuleChangeStatus } from '../entities/priority-rule-change-request.entity';
import { PriorityRuleChangeRequestsService } from '../services/priority-rule-change-requests.service';
/**
* Approval workflow for priority-rule changes. Anyone with the manage
* permission SUBMITS a change; an approver (same permission — the team decides
* who reviews) approves or rejects it. The team is notified at each step.
*/
@ApiTags('priority-rule-change-requests')
@Controller('priority-rule-change-requests')
@ApiBearerAuth()
export class PriorityRuleChangeRequestsController {
constructor(private readonly service: PriorityRuleChangeRequestsService) {}
@Post()
@RuleEngineManage('priority-configs')
@ApiOperation({ summary: 'Submit a priority-rule change for approval' })
submit(
@Body() dto: SubmitPriorityRuleChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.submit(dto, user?.id);
}
@Get()
@RuleEngineView('priority-configs')
@ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'APPROVED', 'REJECTED'] })
@ApiOperation({ summary: 'List priority-rule change requests' })
list(@Query('status') status?: PriorityRuleChangeStatus) {
return this.service.list(status);
}
@Post(':id/approve')
@RuleEngineManage('priority-configs')
@ApiOperation({ summary: 'Approve and apply a pending change' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DecidePriorityRuleChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.approve(id, user?.id, dto.decisionNote);
}
@Post(':id/reject')
@RuleEngineManage('priority-configs')
@ApiOperation({ summary: 'Reject a pending change' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: DecidePriorityRuleChangeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.service.reject(id, user?.id, dto.decisionNote);
}
}

View File

@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
import { CreatePriorityConfigDto } from './create-priority-config.dto';
import { UpdatePriorityConfigDto } from './update-priority-config.dto';
/**
* File a priority-rule change for approval. CREATE carries a full `create`
* payload; UPDATE carries the target id + an `update` patch; DELETE carries
* only the target id.
*/
export class SubmitPriorityRuleChangeDto {
@ApiProperty({ enum: ['CREATE', 'UPDATE', 'DELETE'] })
@IsIn(['CREATE', 'UPDATE', 'DELETE'])
action!: 'CREATE' | 'UPDATE' | 'DELETE';
@ApiPropertyOptional({ description: 'Target rule id (UPDATE / DELETE)' })
@IsOptional()
@IsUUID()
priorityConfigId?: string;
@ApiPropertyOptional({ description: 'Proposed new rule (CREATE)' })
@IsOptional()
@ValidateNested()
@Type(() => CreatePriorityConfigDto)
create?: CreatePriorityConfigDto;
@ApiPropertyOptional({ description: 'Proposed field changes (UPDATE)' })
@IsOptional()
@ValidateNested()
@Type(() => UpdatePriorityConfigDto)
update?: UpdatePriorityConfigDto;
}
export class DecidePriorityRuleChangeDto {
@ApiPropertyOptional({ description: 'Optional note shown to the requester' })
@IsOptional()
@IsString()
@MaxLength(1000)
decisionNote?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { PriorityConfig } from './priority-config.entity';
export type PriorityRuleChangeAction = 'CREATE' | 'UPDATE' | 'DELETE';
export type PriorityRuleChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
/**
* One proposed change to a priority rule, awaiting approval. Every
* create/update/delete of a priority config is filed here first; an approver
* applies (which runs the real mutation, including range-collision checks) or
* rejects it. `payload` holds the proposed field values (null for DELETE);
* `priorityConfigId` the target rule (null for CREATE).
*/
@Entity({ schema: 'freight', name: 'priority_rule_change_requests' })
@Index(['status'])
export class PriorityRuleChangeRequest extends BaseEntity {
@Column({ name: 'action', type: 'varchar', length: 10 })
action!: PriorityRuleChangeAction;
@Column({ name: 'priority_config_id', type: 'uuid', nullable: true })
priorityConfigId?: string | null;
@ManyToOne(() => PriorityConfig, { nullable: true })
@JoinColumn({ name: 'priority_config_id' })
priorityConfig?: PriorityConfig | null;
@Column({ name: 'payload', type: 'jsonb', nullable: true })
payload?: Record<string, unknown> | null;
@Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' })
status!: PriorityRuleChangeStatus;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true })
decidedByUserId?: string | null;
@Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
decidedAt?: Date | null;
@Column({ name: 'decision_note', type: 'text', nullable: true })
decisionNote?: string | null;
}

View File

@@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller
import { CargoTypesController } from './controllers/cargo-types.controller';
import { ContainerTypesController } from './controllers/container-types.controller';
import { PriorityConfigsController } from './controllers/priority-configs.controller';
import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller';
import { RatesController } from './controllers/rates.controller';
import { ServiceTypesController } from './controllers/service-types.controller';
import { ShippingLinesController } from './controllers/shipping-lines.controller';
@@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity';
import { CargoType } from './entities/cargo-type.entity';
import { ContainerType } from './entities/container-type.entity';
import { PriorityConfig } from './entities/priority-config.entity';
import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity';
import { Rate } from './entities/rate.entity';
import { ServiceType } from './entities/service-type.entity';
import { ShippingLine } from './entities/shipping-line.entity';
@@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service';
import { CargoTypesService } from './services/cargo-types.service';
import { ContainerTypesService } from './services/container-types.service';
import { PriorityConfigsService } from './services/priority-configs.service';
import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service';
import { RatesService } from './services/rates.service';
import { ServiceTypesService } from './services/service-types.service';
import { ShippingLinesService } from './services/shipping-lines.service';
@@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service';
import { RuleEngineService } from './rule-engine.service';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoType,
ContainerType,
PriorityConfig,
PriorityRuleChangeRequest,
ServiceType,
WeightLimitRule,
Yard,
@@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
BookingApprovalStep,
BookingRateSnapshot,
]),
// Team notifications for the priority-rule approval workflow.
NotificationInboxModule,
],
controllers: [
CargoTypesController,
ContainerTypesController,
PriorityConfigsController,
PriorityRuleChangeRequestsController,
ServiceTypesController,
WeightLimitRulesController,
YardsController,
@@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
CargoTypesService,
ContainerTypesService,
PriorityConfigsService,
PriorityRuleChangeRequestsService,
ServiceTypesService,
WeightLimitRulesService,
YardsService,

View File

@@ -31,6 +31,12 @@ export class PriorityConfigsService {
async create(dto: CreatePriorityConfigDto): Promise<PriorityConfig> {
this.validateCurrencyField(dto.type, dto.currency);
await this.assertNoRangeCollision({
type: dto.type,
currency: dto.currency ?? null,
minWagonCount: dto.minWagonCount,
maxWagonCount: dto.maxWagonCount,
});
const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {});
@@ -52,6 +58,13 @@ export class PriorityConfigsService {
const type = dto.type ?? existing.type;
const currency = dto.currency !== undefined ? dto.currency : existing.currency;
this.validateCurrencyField(type, currency);
await this.assertNoRangeCollision({
type,
currency: currency ?? null,
minWagonCount: dto.minWagonCount ?? existing.minWagonCount,
maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount,
excludeId: id,
});
const { ...patch } = dto;
const updated = await this.repository.update(id, patch);
@@ -59,6 +72,43 @@ export class PriorityConfigsService {
return updated;
}
/**
* No two rules of the same type (and, for CURRENCY rules, the same currency)
* may cover overlapping wagon-count ranges — a booking must match at most one
* rule per type. Rejects an exact duplicate (15 vs 15) and any partial
* overlap (15 vs 47). Ranges are inclusive on both ends.
*/
async assertNoRangeCollision(input: {
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
currency?: string | null;
minWagonCount: number;
maxWagonCount: number;
excludeId?: string;
}): Promise<void> {
if (input.minWagonCount > input.maxWagonCount) {
throw new BadRequestException(
'Min wagon count cannot be greater than max wagon count',
);
}
const siblings = await this.repository.findAll({
where: { type: input.type },
});
const clash = siblings.find(
(s) =>
s.id !== input.excludeId &&
(input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) &&
input.minWagonCount <= s.maxWagonCount &&
input.maxWagonCount >= s.minWagonCount,
);
if (clash) {
throw new BadRequestException(
`Wagon range ${input.minWagonCount}${input.maxWagonCount} overlaps existing rule ` +
`"${clash.label}" (${clash.minWagonCount}${clash.maxWagonCount}). ` +
'Adjust the range so rules do not collide.',
);
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.repository.softDelete(id);

View File

@@ -0,0 +1,226 @@
import {
NotificationAudience,
NotificationType,
} from '@edr/types';
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service';
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto';
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
import {
PriorityRuleChangeRequest,
PriorityRuleChangeStatus,
} from '../entities/priority-rule-change-request.entity';
import { PriorityConfigsService } from './priority-configs.service';
/** Backoffice rule-engine page — where both queue and rules live. */
const RULES_LINK = '/dashboard/rules/priority-configs';
/**
* Approval workflow for priority-rule changes. Nobody mutates priority configs
* directly any more: a change is SUBMITTED here (validated up front so the
* requester gets immediate feedback on range collisions), the team is
* notified, and an approver later applies or rejects it. Applying re-runs the
* full validation — the winning state is whatever is true at approval time.
*/
@Injectable()
export class PriorityRuleChangeRequestsService {
private readonly logger = new Logger(PriorityRuleChangeRequestsService.name);
constructor(
@InjectRepository(PriorityRuleChangeRequest)
private readonly repo: Repository<PriorityRuleChangeRequest>,
private readonly configs: PriorityConfigsService,
private readonly inbox: NotificationInboxService,
) {}
async submit(
dto: SubmitPriorityRuleChangeDto,
userId?: string | null,
): Promise<PriorityRuleChangeRequest> {
const payload = await this.validateSubmission(dto);
const request = await this.repo.save(
this.repo.create({
action: dto.action,
priorityConfigId: dto.priorityConfigId ?? null,
payload,
status: 'PENDING',
requestedByUserId: userId ?? null,
}),
);
this.notifyTeam(
'Priority rule change submitted',
`A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`,
request,
);
return request;
}
async list(status?: PriorityRuleChangeStatus): Promise<PriorityRuleChangeRequest[]> {
return this.repo.find({
where: status ? { status } : {},
relations: { priorityConfig: true },
order: { createdAt: 'DESC' },
});
}
async approve(
id: string,
userId?: string | null,
decisionNote?: string,
): Promise<PriorityRuleChangeRequest> {
const request = await this.findPending(id);
// Apply the change through the normal service so currency + range-collision
// validation runs against the CURRENT rules; a stale request that now
// collides fails here and stays PENDING for the approver to see the error.
if (request.action === 'CREATE') {
await this.configs.create(request.payload as unknown as CreatePriorityConfigDto);
} else if (request.action === 'UPDATE') {
await this.configs.update(
this.requireTarget(request),
request.payload as unknown as UpdatePriorityConfigDto,
);
} else {
await this.configs.remove(this.requireTarget(request));
}
request.status = 'APPROVED';
request.decidedByUserId = userId ?? null;
request.decidedAt = new Date();
request.decisionNote = decisionNote ?? null;
const saved = await this.repo.save(request);
this.notifyTeam(
'Priority rule change approved',
`The ${request.action.toLowerCase()} priority-rule change was approved and applied.` +
(decisionNote ? ` Note: ${decisionNote}` : ''),
saved,
);
return saved;
}
async reject(
id: string,
userId?: string | null,
decisionNote?: string,
): Promise<PriorityRuleChangeRequest> {
const request = await this.findPending(id);
request.status = 'REJECTED';
request.decidedByUserId = userId ?? null;
request.decidedAt = new Date();
request.decisionNote = decisionNote ?? null;
const saved = await this.repo.save(request);
this.notifyTeam(
'Priority rule change rejected',
`The ${request.action.toLowerCase()} priority-rule change was rejected.` +
(decisionNote ? ` Note: ${decisionNote}` : ''),
saved,
);
return saved;
}
/**
* Validate a submission the way applying it would, so bad requests are
* refused at the door — most importantly the wagon-range collision rule.
* Returns the payload to persist.
*/
private async validateSubmission(
dto: SubmitPriorityRuleChangeDto,
): Promise<Record<string, unknown> | null> {
if (dto.action === 'CREATE') {
if (!dto.create) {
throw new BadRequestException('CREATE requires the proposed rule in `create`');
}
await this.configs.assertNoRangeCollision({
type: dto.create.type,
currency: dto.create.currency ?? null,
minWagonCount: dto.create.minWagonCount,
maxWagonCount: dto.create.maxWagonCount,
});
return { ...dto.create };
}
if (!dto.priorityConfigId) {
throw new BadRequestException(`${dto.action} requires priorityConfigId`);
}
const existing = await this.configs.findById(dto.priorityConfigId);
if (dto.action === 'DELETE') return null;
if (!dto.update || Object.keys(dto.update).length === 0) {
throw new BadRequestException('UPDATE requires the field changes in `update`');
}
await this.configs.assertNoRangeCollision({
type: dto.update.type ?? existing.type,
currency:
dto.update.currency !== undefined ? dto.update.currency : existing.currency,
minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount,
maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount,
excludeId: existing.id,
});
return { ...dto.update };
}
private async findPending(id: string): Promise<PriorityRuleChangeRequest> {
const request = await this.repo.findOne({
where: { id },
relations: { priorityConfig: true },
});
if (!request) throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== 'PENDING') {
throw new ConflictException(
`Change request is already ${request.status.toLowerCase()}`,
);
}
return request;
}
private requireTarget(request: PriorityRuleChangeRequest): string {
if (!request.priorityConfigId) {
throw new BadRequestException(
`${request.action} change request has no target rule`,
);
}
return request.priorityConfigId;
}
/**
* In-app notification to the whole backoffice team (submission AND decision
* both notify the team; the requester is staff, so they are included).
* Fire-and-forget — a notification failure never blocks the workflow.
*/
private notifyTeam(
title: string,
body: string,
request: PriorityRuleChangeRequest,
): void {
void this.inbox
.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title,
body,
link: RULES_LINK,
data: { priorityRuleChangeRequestId: request.id, action: request.action },
})
.catch((err) =>
this.logger.warn(
`Priority-rule notification failed: ${(err as Error).message}`,
),
);
}
}