mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
feat: add pagination to schedule history and consolidation approvals
- Implemented pagination in ScheduleHistoryPanel to manage large history entries. - Updated API to support pagination parameters for schedule history. - Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows. - Introduced new types for paginated responses in bookings and train scheduling services. - Added a database migration to create an index on wagon_booking_allocations for performance improvements.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ConsolidationApprovalService,
|
||||
CONSOLIDATION_APPROVAL_PENDING,
|
||||
} from './consolidation-approval.service';
|
||||
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
} from "./consolidation-approval.service";
|
||||
import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
||||
@@ -14,37 +14,53 @@ import { Booking } from './entities/booking.entity';
|
||||
* decision on one side of a shared wagon is meaningless without the other), and
|
||||
* a decided pairing cannot be decided twice.
|
||||
*/
|
||||
describe('ConsolidationApprovalService', () => {
|
||||
describe("ConsolidationApprovalService", () => {
|
||||
const PENDING = {
|
||||
id: 'ap-1',
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
id: "ap-1",
|
||||
bookingId: "b-1",
|
||||
partnerBookingId: "b-2",
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
requestedBy: 'gl-user',
|
||||
requestedBy: "gl-user",
|
||||
};
|
||||
|
||||
function makeService(overrides: {
|
||||
approvals?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
} = {}) {
|
||||
function makeService(
|
||||
overrides: {
|
||||
approvals?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsService?: Partial<Record<string, jest.Mock>>;
|
||||
/** Yard ids the caller is scoped to; null = unrestricted. */
|
||||
yardScope?: string[] | null;
|
||||
} = {},
|
||||
) {
|
||||
const approvals = {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
||||
findById: jest.fn().mockResolvedValue(PENDING),
|
||||
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
|
||||
create: jest.fn().mockResolvedValue({ id: "ap-1" }),
|
||||
decide: jest.fn().mockResolvedValue(true),
|
||||
findQueue: jest.fn().mockResolvedValue([]),
|
||||
findQueuePage: jest.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
countByStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ PENDING: 2, APPROVED: 4, REJECTED: 1 }),
|
||||
findAllForBooking: jest.fn().mockResolvedValue([]),
|
||||
...overrides.approvals,
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
resolveStaffNames: jest.fn().mockResolvedValue(new Map()),
|
||||
...overrides.bookingsRepository,
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn(async (id: string) =>
|
||||
({ id, reference: `BK-${id}` }) as Booking,
|
||||
findById: jest.fn(
|
||||
async (id: string) =>
|
||||
({
|
||||
id,
|
||||
reference: `BK-${id}`,
|
||||
originYardId: "mojo",
|
||||
destinationYardId: "djibouti",
|
||||
}) as Booking,
|
||||
),
|
||||
...overrides.bookingsService,
|
||||
};
|
||||
const notifier = {
|
||||
consolidationApprovalRequestedToStaff: jest.fn(),
|
||||
@@ -55,6 +71,11 @@ describe('ConsolidationApprovalService', () => {
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||
};
|
||||
const yardScope = {
|
||||
getScopedYardIds: jest
|
||||
.fn()
|
||||
.mockResolvedValue(overrides.yardScope ?? null),
|
||||
};
|
||||
|
||||
const service = new ConsolidationApprovalService(
|
||||
approvals as never,
|
||||
@@ -62,27 +83,28 @@ describe('ConsolidationApprovalService', () => {
|
||||
bookingsService as never,
|
||||
notifier as never,
|
||||
dataSource as never,
|
||||
yardScope as never,
|
||||
);
|
||||
return { service, approvals, bookingsRepository, notifier };
|
||||
return { service, approvals, bookingsRepository, notifier, yardScope };
|
||||
}
|
||||
|
||||
it('holds BOTH halves at the gate when a pairing is created', async () => {
|
||||
it("holds BOTH halves at the gate when a pairing is created", async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
await service.requestApproval("b-1", "b-2", "gl-user");
|
||||
|
||||
expect(approvals.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
requestedBy: 'gl-user',
|
||||
bookingId: "b-1",
|
||||
partnerBookingId: "b-2",
|
||||
requestedBy: "gl-user",
|
||||
}),
|
||||
);
|
||||
// Neither half may sit in the operations queue while the wagon is unreviewed.
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(
|
||||
@@ -90,94 +112,102 @@ describe('ConsolidationApprovalService', () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open a second review for a pairing already pending', async () => {
|
||||
it("does not open a second review for a pairing already pending", async () => {
|
||||
const { service, approvals } = makeService({
|
||||
approvals: {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
const result = await service.requestApproval("b-1", "b-2", "gl-user");
|
||||
|
||||
expect(result).toBe(PENDING);
|
||||
expect(approvals.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
|
||||
it("releases BOTH halves to Operations on approval, logging who decided", async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.approve('ap-1', 'approver-1', 'looks fine');
|
||||
await service.approve("ap-1", "approver-1", "looks fine");
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
"ap-1",
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
'approver-1',
|
||||
'looks fine',
|
||||
"approver-1",
|
||||
"looks fine",
|
||||
[
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
],
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
// Operations only learns about the pair now — the gate is what kept it out.
|
||||
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
|
||||
it("sends BOTH halves back to GL on rejection, with the reason on each", async () => {
|
||||
const { service, approvals, bookingsRepository } = makeService();
|
||||
|
||||
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
|
||||
await service.reject("ap-1", "approver-1", "partner cargo is wrong");
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
"ap-1",
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
'approver-1',
|
||||
'partner cargo is wrong',
|
||||
"approver-1",
|
||||
"partner cargo is wrong",
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
"b-1",
|
||||
"partner cargo is wrong",
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-2',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
"b-2",
|
||||
"partner cargo is wrong",
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: "OPERATION_CHANGES_REQUESTED",
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||
status: "OPERATION_CHANGES_REQUESTED",
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the requester approve their own pairing', async () => {
|
||||
it("lets the requester approve their own pairing", async () => {
|
||||
// No maker-checker separation: the permission alone decides who may approve,
|
||||
// and the audit trail still records requester and approver separately.
|
||||
const { service, approvals } = makeService();
|
||||
|
||||
await service.approve('ap-1', 'gl-user');
|
||||
await service.approve("ap-1", "gl-user");
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
"ap-1",
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
'gl-user',
|
||||
"gl-user",
|
||||
undefined,
|
||||
[
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a reason to reject', async () => {
|
||||
it("requires a reason to reject", async () => {
|
||||
const { service, approvals } = makeService();
|
||||
|
||||
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
|
||||
await expect(service.reject("ap-1", "approver-1", " ")).rejects.toThrow(
|
||||
/reason is required/i,
|
||||
);
|
||||
expect(approvals.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a pairing that was already decided', async () => {
|
||||
it("refuses a pairing that was already decided", async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
@@ -187,21 +217,203 @@ describe('ConsolidationApprovalService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
|
||||
/already approved/i,
|
||||
);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loses cleanly when another approver decides the same pairing first', async () => {
|
||||
it("loses cleanly when another approver decides the same pairing first", async () => {
|
||||
// decide() writes only against a still-PENDING row, so the loser of the race
|
||||
// affects nothing and must not move the bookings.
|
||||
const { service } = makeService({
|
||||
approvals: { decide: jest.fn().mockResolvedValue(false) },
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
|
||||
/already decided by someone else/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("approves a pairing that was rejected earlier, releasing both halves", async () => {
|
||||
// A rejection is not final: the reviewer may change their mind, or GL may
|
||||
// argue the case. Only an already-approved pairing is closed.
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy: "approver-1",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await service.approve("ap-1", "approver-2", "resolved with GL");
|
||||
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to reject a pairing that was already rejected", async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Rejected,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.reject("ap-1", "approver-1", "still wrong"),
|
||||
).rejects.toThrow(/already rejected/i);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the requester and the decider on every queue row", async () => {
|
||||
// The stored ids mean nothing to a reviewer reading the history.
|
||||
const { service } = makeService({
|
||||
approvals: {
|
||||
findQueuePage: jest.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Approved,
|
||||
decidedBy: "approver-1",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
bookingsRepository: {
|
||||
resolveStaffNames: jest.fn().mockResolvedValue(
|
||||
new Map([
|
||||
["gl-user", "Selam GL"],
|
||||
["approver-1", "Abebe Approver"],
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const { items, meta, counts } = await service.queue({ pageSize: 10 });
|
||||
|
||||
expect(items[0].requestedByName).toBe("Selam GL");
|
||||
expect(items[0].decidedByName).toBe("Abebe Approver");
|
||||
// Badges count the whole queue, not the page that happened to load.
|
||||
expect(counts.APPROVED).toBe(4);
|
||||
expect(meta).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("pages the queue in SQL and reports the page meta", async () => {
|
||||
// The page must be cut in the query, not sliced out of a full fetch —
|
||||
// otherwise ordering only holds within whatever page loaded.
|
||||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 25 });
|
||||
const { service } = makeService({ approvals: { findQueuePage } });
|
||||
|
||||
const { meta } = await service.queue({
|
||||
status: ConsolidationApprovalStatus.Rejected,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
expect(findQueuePage).toHaveBeenCalledWith({
|
||||
status: ConsolidationApprovalStatus.Rejected,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(meta).toMatchObject({
|
||||
page: 2,
|
||||
totalPages: 3,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("narrows the queue and the badges to the caller's yards", async () => {
|
||||
// A Mojo + Adama desk sees both yards' pairings, and nothing else. The
|
||||
// badges must be narrowed too, or they promise rows the caller cannot open.
|
||||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
|
||||
const countByStatus = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ PENDING: 1, APPROVED: 0, REJECTED: 0 });
|
||||
const { service } = makeService({
|
||||
approvals: { findQueuePage, countByStatus },
|
||||
yardScope: ["mojo", "adama"],
|
||||
});
|
||||
|
||||
await service.queue({ user: { id: "u-1" }, page: 1, pageSize: 10 });
|
||||
|
||||
expect(findQueuePage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ yardIds: ["mojo", "adama"] }),
|
||||
);
|
||||
expect(countByStatus).toHaveBeenCalledWith(["mojo", "adama"]);
|
||||
});
|
||||
|
||||
it("leaves the queue unnarrowed for an unrestricted caller", async () => {
|
||||
// Super admin, `yards:view_all`, or a desk with no yard mapping at all —
|
||||
// the mapping narrows access, it never grants it.
|
||||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
|
||||
const { service } = makeService({
|
||||
approvals: { findQueuePage },
|
||||
yardScope: null,
|
||||
});
|
||||
|
||||
await service.queue({ user: { id: "u-1" } });
|
||||
|
||||
expect(findQueuePage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ yardIds: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to decide a pairing outside the caller's yards", async () => {
|
||||
// Hiding the row is not enough — the id is guessable from a shared link,
|
||||
// and deciding moves two other yards' bookings.
|
||||
const { service, bookingsRepository } = makeService({
|
||||
yardScope: ["adama"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.approve("ap-1", "approver-1", undefined, { id: "u-1" }),
|
||||
).rejects.toThrow(/outside your assigned yards/i);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a decision when only the PARTNER half touches the caller's yard", async () => {
|
||||
// The pair is one decision, so seeing one side is seeing the pairing.
|
||||
const { service, bookingsRepository } = makeService({
|
||||
yardScope: ["dire-dawa"],
|
||||
bookingsService: {
|
||||
findById: jest.fn(async (id: string) =>
|
||||
id === "b-2"
|
||||
? ({
|
||||
id,
|
||||
reference: "BK-b-2",
|
||||
originYardId: "djibouti",
|
||||
destinationYardId: "dire-dawa",
|
||||
} as Booking)
|
||||
: ({
|
||||
id,
|
||||
reference: "BK-b-1",
|
||||
originYardId: "mojo",
|
||||
destinationYardId: "djibouti",
|
||||
} as Booking),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await service.approve("ap-1", "approver-1", undefined, { id: "u-1" });
|
||||
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -18,6 +19,7 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
|
||||
|
||||
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||
@@ -25,6 +27,12 @@ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||
/** The gate's own holding status — neither half reaches Operations from here. */
|
||||
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
|
||||
|
||||
/** An approval row with the requester's and decider's names resolved. */
|
||||
export type ConsolidationApprovalView = ConsolidationApproval & {
|
||||
requestedByName: string | null;
|
||||
decidedByName: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate.
|
||||
*
|
||||
@@ -53,6 +61,7 @@ export class ConsolidationApprovalService {
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly yardScope: YardScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -116,8 +125,16 @@ export class ConsolidationApprovalService {
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
note?: string,
|
||||
user?: unknown,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
const approval = await this.loadPending(approvalId);
|
||||
// A pairing that was rejected can still be approved later — the reviewer
|
||||
// changed their mind, or GL argued the case. Only an already-approved one
|
||||
// is final, since both halves have moved on to Operations by then.
|
||||
const approval = await this.loadDecidable(approvalId, [
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
]);
|
||||
await this.assertInScope(approval, user);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
@@ -125,6 +142,10 @@ export class ConsolidationApprovalService {
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
decidedBy,
|
||||
note,
|
||||
[
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
],
|
||||
);
|
||||
// Lost the race to another approver deciding the same pairing.
|
||||
if (!claimed) {
|
||||
@@ -162,13 +183,17 @@ export class ConsolidationApprovalService {
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
reason: string,
|
||||
user?: unknown,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
if (!reason?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A reason is required to reject a consolidation.",
|
||||
);
|
||||
}
|
||||
const approval = await this.loadPending(approvalId);
|
||||
const approval = await this.loadDecidable(approvalId, [
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
]);
|
||||
await this.assertInScope(approval, user);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
@@ -212,9 +237,88 @@ export class ConsolidationApprovalService {
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/** Pending pairings awaiting a decision, oldest first. */
|
||||
queue(): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findQueue();
|
||||
/**
|
||||
* One page of the review queue, or of its history: pending pairings first,
|
||||
* then the decided ones, each carrying the display name of whoever requested
|
||||
* and whoever decided it — the stored ids tell a reviewer nothing.
|
||||
*
|
||||
* `user` narrows the whole thing to the caller's yards: a Mojo desk sees the
|
||||
* pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees
|
||||
* both yards' pairings. The counts behind the tabs are narrowed the same way,
|
||||
* so a badge never promises rows the caller cannot open.
|
||||
*/
|
||||
async queue(options?: {
|
||||
status?: ConsolidationApprovalStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** The `/auth/me` caller. Omit only for internal, unscoped reads. */
|
||||
user?: unknown;
|
||||
}): Promise<{
|
||||
items: ConsolidationApprovalView[];
|
||||
total: number;
|
||||
/** Counts per status within the caller's scope — the tab badges. */
|
||||
counts: Record<ConsolidationApprovalStatus, number>;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}> {
|
||||
const page = Math.max(1, options?.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10));
|
||||
const yardIds = await this.scopedYardIds(options?.user);
|
||||
|
||||
const { items: rows, total } = await this.approvals.findQueuePage({
|
||||
status: options?.status,
|
||||
yardIds,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
const counts = await this.approvals.countByStatus(yardIds);
|
||||
const names = await this.bookingsRepository.resolveStaffNames(
|
||||
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
|
||||
);
|
||||
const items = rows.map((row) => ({
|
||||
...row,
|
||||
requestedByName: row.requestedBy
|
||||
? (names.get(row.requestedBy) ?? null)
|
||||
: null,
|
||||
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
|
||||
}));
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Yard ids the caller may see, or undefined for unrestricted.
|
||||
*
|
||||
* Scope comes from the desk they are logged in as: `yard_positions` maps a
|
||||
* position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a
|
||||
* `yards:view_all` holder, and a desk with NO yard mapping all resolve to
|
||||
* unrestricted — the mapping narrows access, it never grants it.
|
||||
*
|
||||
* Called with no user only from internal paths, which are unscoped.
|
||||
*/
|
||||
private async scopedYardIds(user: unknown): Promise<string[] | undefined> {
|
||||
if (!user) return undefined;
|
||||
const scope = await this.yardScope.getScopedYardIds(user as never);
|
||||
return scope ?? undefined;
|
||||
}
|
||||
|
||||
/** Full decision history for one booking — who decided what, and when. */
|
||||
@@ -227,12 +331,46 @@ export class ConsolidationApprovalService {
|
||||
return this.approvals.findPendingForBooking(bookingId);
|
||||
}
|
||||
|
||||
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
|
||||
/**
|
||||
* Refuse a decision on a pairing outside the caller's yards.
|
||||
*
|
||||
* Hiding the row from the list is not enough on its own: the id is guessable
|
||||
* from a shared link, and deciding a pairing moves two other yards' bookings.
|
||||
* Same rule as the list — either half's origin or destination is enough.
|
||||
*/
|
||||
private async assertInScope(
|
||||
approval: ConsolidationApproval,
|
||||
user: unknown,
|
||||
): Promise<void> {
|
||||
const yardIds = await this.scopedYardIds(user);
|
||||
if (!yardIds) return;
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
const touches = (b: Booking | null | undefined) =>
|
||||
!!b &&
|
||||
(yardIds.includes(b.originYardId) ||
|
||||
yardIds.includes(b.destinationYardId));
|
||||
|
||||
if (!touches(booking) && !touches(partner)) {
|
||||
throw new ForbiddenException(
|
||||
"This shared wagon is outside your assigned yards.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Load a row and refuse it unless it is in one of the decidable states. */
|
||||
private async loadDecidable(
|
||||
approvalId: string,
|
||||
allowed: ConsolidationApprovalStatus[],
|
||||
): Promise<ConsolidationApproval> {
|
||||
const approval = await this.approvals.findById(approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Approval ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ConsolidationApprovalStatus.Pending) {
|
||||
if (!allowed.includes(approval.status)) {
|
||||
throw new ConflictException(
|
||||
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DataSource, In, Repository } from "typeorm";
|
||||
import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import {
|
||||
ConsolidationApproval,
|
||||
ConsolidationApprovalStatus,
|
||||
} from "./entities/consolidation-approval.entity";
|
||||
|
||||
/**
|
||||
* Narrow a queue query to the caller's yards.
|
||||
*
|
||||
* A shared wagon is visible when EITHER half of it starts or ends at one of
|
||||
* those yards — the pairing is one decision, so seeing one side is seeing the
|
||||
* pairing. Yards the train merely passes through do not count: only the two
|
||||
* bookings' own endpoints do.
|
||||
*
|
||||
* `undefined` means unrestricted and adds no predicate. An EMPTY array means
|
||||
* scoped-to-nothing and must match no rows — `IN ()` is not valid SQL, so it
|
||||
* gets an explicit false instead of being skipped.
|
||||
*/
|
||||
function applyYardScope(
|
||||
qb: SelectQueryBuilder<ConsolidationApproval>,
|
||||
yardIds: string[] | undefined,
|
||||
): void {
|
||||
if (!yardIds) return;
|
||||
if (!yardIds.length) {
|
||||
qb.andWhere("1 = 0");
|
||||
return;
|
||||
}
|
||||
qb.andWhere(
|
||||
`(booking.originYardId IN (:...yardIds)
|
||||
OR booking.destinationYardId IN (:...yardIds)
|
||||
OR partnerBooking.originYardId IN (:...yardIds)
|
||||
OR partnerBooking.destinationYardId IN (:...yardIds))`,
|
||||
{ yardIds },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence for the shared-wagon approval gate. Rows are never deleted —
|
||||
* decided rows are the audit trail of who approved which pairing and when.
|
||||
@@ -48,16 +78,91 @@ export class ConsolidationApprovalsRepository {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/** Pending requests for the review queue, oldest first (FIFO). */
|
||||
findQueue(): Promise<ConsolidationApproval[]> {
|
||||
return this.repository.find({
|
||||
where: { status: ConsolidationApprovalStatus.Pending },
|
||||
relations: {
|
||||
booking: { company: true },
|
||||
partnerBooking: { company: true },
|
||||
},
|
||||
order: { requestedAt: "ASC" },
|
||||
});
|
||||
/**
|
||||
* One page of review-queue rows, with both bookings loaded.
|
||||
*
|
||||
* Pending rows are work still to do, so they come oldest first (FIFO) and
|
||||
* ahead of everything else. Decided rows are history, so they come
|
||||
* newest-decision-first. Ordering is done in SQL, not after the fact — a page
|
||||
* sorted in memory would only be sorted within itself.
|
||||
*
|
||||
* `yardIds` narrows to the caller's yards (see YardScopeService); pass
|
||||
* undefined for an unrestricted caller. The narrowing is a WHERE, not a
|
||||
* post-filter, so the page and the total both count only visible rows.
|
||||
*/
|
||||
async findQueuePage(options: {
|
||||
status?: ConsolidationApprovalStatus;
|
||||
yardIds?: string[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<{ items: ConsolidationApproval[]; total: number }> {
|
||||
const { status, yardIds, page, pageSize } = options;
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("approval")
|
||||
.leftJoinAndSelect("approval.booking", "booking")
|
||||
.leftJoinAndSelect("booking.company", "company")
|
||||
.leftJoinAndSelect("approval.partnerBooking", "partnerBooking")
|
||||
.leftJoinAndSelect("partnerBooking.company", "partnerCompany");
|
||||
|
||||
if (status) {
|
||||
qb.andWhere("approval.status = :status", { status });
|
||||
} else {
|
||||
qb.addOrderBy(
|
||||
`CASE WHEN approval.status = '${ConsolidationApprovalStatus.Pending}' THEN 0 ELSE 1 END`,
|
||||
"ASC",
|
||||
);
|
||||
}
|
||||
|
||||
applyYardScope(qb, yardIds);
|
||||
|
||||
// Pending has no decidedAt, decided rows all do — one pair of keys orders
|
||||
// both groups correctly whichever tab asked.
|
||||
const [items, total] = await qb
|
||||
.addOrderBy("approval.decidedAt", "DESC", "NULLS FIRST")
|
||||
.addOrderBy("approval.requestedAt", "ASC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Row count per status, for the tab badges — those must show the whole
|
||||
* queue, not just the page currently loaded. Narrowed by the same yard scope
|
||||
* as the list, so a badge never promises rows the caller cannot open.
|
||||
*/
|
||||
async countByStatus(
|
||||
yardIds?: string[],
|
||||
): Promise<Record<ConsolidationApprovalStatus, number>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("approval")
|
||||
.select("approval.status", "status")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.groupBy("approval.status");
|
||||
|
||||
// The scope predicate reads both bookings, so it needs them joined even
|
||||
// though the count itself selects no columns from them.
|
||||
if (yardIds) {
|
||||
qb.leftJoin("approval.booking", "booking").leftJoin(
|
||||
"approval.partnerBooking",
|
||||
"partnerBooking",
|
||||
);
|
||||
}
|
||||
applyYardScope(qb, yardIds);
|
||||
|
||||
const rows = await qb.getRawMany<{
|
||||
status: ConsolidationApprovalStatus;
|
||||
count: string;
|
||||
}>();
|
||||
|
||||
const counts = {
|
||||
[ConsolidationApprovalStatus.Pending]: 0,
|
||||
[ConsolidationApprovalStatus.Approved]: 0,
|
||||
[ConsolidationApprovalStatus.Rejected]: 0,
|
||||
};
|
||||
for (const row of rows) counts[row.status] = Number(row.count);
|
||||
return counts;
|
||||
}
|
||||
|
||||
create(input: {
|
||||
@@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository {
|
||||
| ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy: string | null,
|
||||
decisionNote?: string | null,
|
||||
/** Statuses the row may be claimed FROM. Defaults to pending-only. */
|
||||
from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending],
|
||||
): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, status: ConsolidationApprovalStatus.Pending },
|
||||
{ id, status: In(from) },
|
||||
{
|
||||
status,
|
||||
decidedBy,
|
||||
@@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository {
|
||||
if (bookingIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository.find({
|
||||
where: [
|
||||
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
bookingId: In(bookingIds),
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
},
|
||||
{
|
||||
partnerBookingId: In(bookingIds),
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
|
||||
Reference in New Issue
Block a user