mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
242 lines
8.1 KiB
TypeScript
242 lines
8.1 KiB
TypeScript
import {
|
|
NotificationAudience,
|
|
NotificationType,
|
|
} from '@edr/types';
|
|
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
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';
|
|
import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry';
|
|
|
|
/** 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,
|
|
canSelfApprove = false,
|
|
): Promise<PriorityRuleChangeRequest> {
|
|
const request = await this.findPending(id);
|
|
|
|
// Separation of duties: the requester cannot approve their own change —
|
|
// except super admins, who have full backoffice authority.
|
|
// TODO: split approval into a distinct approver permission rather than
|
|
// relying on this id check.
|
|
if (!canSelfApprove && userId && userId === request.requestedByUserId) {
|
|
throw new ForbiddenException(
|
|
'You cannot approve a change request you submitted',
|
|
);
|
|
}
|
|
|
|
// 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: {
|
|
permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification],
|
|
},
|
|
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}`,
|
|
),
|
|
);
|
|
}
|
|
}
|