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 { SubmitRateChangeDto } from '../dto/rate-change-request.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { RateChangeRequest, RateChangeStatus, } from '../entities/rate-change-request.entity'; import { Rate } from '../entities/rate.entity'; import { RatesService } from './rates.service'; import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; /** Backoffice page where both the queue and the rates live. */ const RATES_LINK = '/dashboard/rules/rates'; /** Fields a change request may carry — anything else in the patch is ignored. */ const DIFFABLE_FIELDS = [ 'rateValue', 'currency', 'rateUnit', 'appliesTo', 'trigger', 'tradeDirection', 'containerTypeId', 'cargoTypeId', // The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate // diffed to nothing and the submit was refused as "nothing changed". 'originYardId', 'destinationYardId', // Container last-mile distance bands. Missing here, a band-range edit on a // LIVE last-mile rate would diff to "nothing changed". 'minKm', 'maxKm', // PER_LITER fuel surcharge billing base. Missing here, a switch to PER_LITER // dropped the submitted liters and validation failed with "needs a base // liters amount" even though the payload carried one. 'baseLiters', ] as const; /** * Approval workflow for edits to LIVE rates. * * A LIVE rate is what pricing charges right now, so it is never edited in * place. The edit is filed here as a PENDING request and the live row keeps * its old value — a rate at 100 USD keeps quoting 100 while a change to 200 * waits. Approval replays the edit through RatesService, so every rule * (unit validity, pattern uniqueness) is re-checked against whatever is true * at approval time, not at submit time. */ @Injectable() export class RateChangeRequestsService { private readonly logger = new Logger(RateChangeRequestsService.name); constructor( @InjectRepository(RateChangeRequest) private readonly repo: Repository, private readonly rates: RatesService, private readonly inbox: NotificationInboxService, ) {} /** * File an edit against a LIVE rate. Validated up front so the requester * hears about a bad unit or a pattern clash immediately rather than the * approver hitting it days later. */ async submit(dto: SubmitRateChangeDto, userId?: string | null): Promise { const rate = await this.rates.findById(dto.rateId); if (rate.status !== 'LIVE') { throw new BadRequestException( `Only LIVE rates go through approval — this rate is ${rate.status} and can be edited directly.`, ); } const payload = this.changedFieldsOnly(rate, dto.update); if (Object.keys(payload).length === 0) { throw new BadRequestException('Nothing changed — the proposed values match the live rate.'); } // One pending edit per rate: two racing requests would both validate, then // the second would silently overwrite the first on approval. const inFlight = await this.repo.findOne({ where: { rateId: dto.rateId, status: 'PENDING' }, }); if (inFlight) { throw new ConflictException( 'This rate already has a change awaiting approval. Have it approved or rejected first.', ); } await this.rates.assertUpdateValid(dto.rateId, payload as UpdateRateDto); const request = await this.repo.save( this.repo.create({ rateId: dto.rateId, payload, previousValues: this.snapshot(rate, payload), status: 'PENDING', requestedByUserId: userId ?? null, }), ); this.notifyTeam( 'Rate change submitted', `A change to a LIVE rate was submitted and awaits approval. The current rate stays in effect until it is approved.`, request, ); return request; } async list(status?: RateChangeStatus): Promise { return this.repo.find({ where: status ? { status } : {}, relations: { rate: true }, order: { createdAt: 'DESC' }, }); } /** * Approve and apply. The live mutation runs FIRST — if it now fails (someone * created a clashing rate since submit), the request stays PENDING and the * approver sees the real error instead of a request marked approved that * never landed. */ 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 repricing — // except super admins, who have full backoffice authority. if (!canSelfApprove && userId && userId === request.requestedByUserId) { throw new ForbiddenException('You cannot approve a rate change you submitted'); } await this.rates.applyApprovedUpdate(request.rateId, request.payload as UpdateRateDto); request.status = 'APPROVED'; request.decidedByUserId = userId ?? null; request.decidedAt = new Date(); request.decisionNote = decisionNote ?? null; const saved = await this.repo.save(request); this.notifyTeam( 'Rate change approved', `The rate change was approved and is now live.` + (decisionNote ? ` Note: ${decisionNote}` : ''), saved, ); return saved; } /** Reject — the live rate is never touched, so it simply keeps its value. */ 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( 'Rate change rejected', `The rate change was rejected — the rate keeps its current value.` + (decisionNote ? ` Note: ${decisionNote}` : ''), saved, ); return saved; } /** * Keep only fields the requester actually changed. A form posts every field * back, so without this the diff would list untouched values as changes. */ private changedFieldsOnly(rate: Rate, update: UpdateRateDto): Record { const patch: Record = {}; for (const field of DIFFABLE_FIELDS) { const proposed = (update as Record)[field]; if (proposed === undefined) continue; if (this.sameValue(proposed, (rate as unknown as Record)[field])) continue; patch[field] = proposed; } return patch; } /** The live values the patch would overwrite — the "before" side of the diff. */ private snapshot(rate: Rate, payload: Record): Record { const before: Record = {}; for (const field of Object.keys(payload)) { before[field] = (rate as unknown as Record)[field] ?? null; } return before; } /** * rateValue arrives as a string from Postgres `numeric` but as a number from * the form, so 100 and "100.0000" must compare equal or every submit would * look like a change. */ private sameValue(a: unknown, b: unknown): boolean { if (a === b) return true; if (a == null && b == null) return true; if (a == null || b == null) return false; const numA = Number(a); const numB = Number(b); if (!Number.isNaN(numA) && !Number.isNaN(numB) && a !== '' && b !== '') { return numA === numB; } return String(a) === String(b); } private async findPending(id: string): Promise { const request = await this.repo.findOne({ where: { id }, relations: { rate: true } }); if (!request) throw new NotFoundException(`Rate change request ${id} not found`); if (request.status !== 'PENDING') { throw new ConflictException(`Rate change request is already ${request.status.toLowerCase()}`); } return request; } /** Fire-and-forget — a notification failure never blocks the workflow. */ private notifyTeam(title: string, body: string, request: RateChangeRequest): void { void this.inbox .notify({ recipients: { permissionKeys: [FREIGHT_PERMS.ruleEngine.getNotification], }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.REQUEST_SUBMITTED, title, body, link: RATES_LINK, data: { rateChangeRequestId: request.id, rateId: request.rateId }, }) .catch((err) => this.logger.warn(`Rate-change notification failed: ${(err as Error).message}`), ); } }