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, private readonly configs: PriorityConfigsService, private readonly inbox: NotificationInboxService, ) {} async submit( dto: SubmitPriorityRuleChangeDto, userId?: string | null, ): Promise { 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 { 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 { 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 { 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 | 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 { 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}`, ), ); } }