mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(auth): resolve position-type permissions so GL staff can open clearance pages
This commit is contained in:
@@ -107,6 +107,7 @@ import { ImportOperationsModule } from "./modules/import-operations/import-opera
|
|||||||
import { AiModule } from "./modules/ai/ai.module";
|
import { AiModule } from "./modules/ai/ai.module";
|
||||||
import { LoggerMiddleware } from "./logger.middleware";
|
import { LoggerMiddleware } from "./logger.middleware";
|
||||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||||
|
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -251,6 +252,9 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
|||||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
PaidImportExportMileDemoSeeder,
|
PaidImportExportMileDemoSeeder,
|
||||||
LoginAudienceMiddleware,
|
LoginAudienceMiddleware,
|
||||||
|
// Feeds position-TYPE grants to the synchronous permission checks — without
|
||||||
|
// it, staff whose permissions live on their position type resolve to none.
|
||||||
|
PositionTypePermissionsCache,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
assertCanApproveContractStep,
|
assertCanApproveContractStep,
|
||||||
canEditContractStep,
|
canEditContractStep,
|
||||||
|
collectPermissionKeys,
|
||||||
|
hasFreightPermission,
|
||||||
|
setPositionTypePermissionResolver,
|
||||||
} from './freight-permission.util';
|
} from './freight-permission.util';
|
||||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||||
|
|
||||||
@@ -49,3 +52,72 @@ describe('canEditContractStep (strict per-step edit gate)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GL lockout regression: positions created through the admin UI keep their
|
||||||
|
* grants on the position TYPE, and the JWT only ever snapshots DIRECT position
|
||||||
|
* permissions. Without the type resolver those staff resolved to zero
|
||||||
|
* permissions, so every gated route rejected them — which is what kept GL
|
||||||
|
* officers out of their own clearance detail pages.
|
||||||
|
*/
|
||||||
|
describe('collectPermissionKeys — position-type grants', () => {
|
||||||
|
const CLEARANCE = FREIGHT_PERMS.contracts.clearanceReview;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setPositionTypePermissionResolver(() => []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const glOfficer = {
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
employee: {
|
||||||
|
position: {
|
||||||
|
permissions: [], // admin-created position carries NO direct grants
|
||||||
|
positionType: { key: 'commercial-global-logistics-(et)-officer' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('resolves permissions carried by the position type', () => {
|
||||||
|
setPositionTypePermissionResolver((key) =>
|
||||||
|
key === 'commercial-global-logistics-(et)-officer' ? [CLEARANCE] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(collectPermissionKeys(glOfficer)).toContain(CLEARANCE);
|
||||||
|
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the array-shaped employee payload too', () => {
|
||||||
|
setPositionTypePermissionResolver(() => [CLEARANCE]);
|
||||||
|
|
||||||
|
const arrayShaped = {
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
employee: [
|
||||||
|
{
|
||||||
|
positions: [
|
||||||
|
{ permissions: [], positionType: { key: 'djibouti-gl-officer' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(hasFreightPermission(arrayShaped, CLEARANCE)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still rejects when neither the position nor its type grants it', () => {
|
||||||
|
setPositionTypePermissionResolver(() => []);
|
||||||
|
|
||||||
|
expect(hasFreightPermission(glOfficer, CLEARANCE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps direct position permissions working with no resolver installed', () => {
|
||||||
|
const direct = {
|
||||||
|
roles: [],
|
||||||
|
permissions: [],
|
||||||
|
employee: { position: { permissions: [{ key: CLEARANCE }] } },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(hasFreightPermission(direct, CLEARANCE)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -42,12 +42,41 @@ export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boo
|
|||||||
return isSuperAdmin(user) || isOrganizationAdmin(user);
|
return isSuperAdmin(user) || isOrganizationAdmin(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flat permission keys from JWT / session user (roles + position permissions). */
|
/**
|
||||||
|
* Permissions carried by a position TYPE rather than the position itself.
|
||||||
|
*
|
||||||
|
* The JWT snapshots only DIRECT position permissions, so type-level grants —
|
||||||
|
* which is where admin-created positions keep theirs — are absent from the
|
||||||
|
* token entirely. This resolver is installed at startup
|
||||||
|
* (see `PositionTypePermissionsCache`) so the synchronous permission checks
|
||||||
|
* below can still see them. Left as a no-op resolver until then, which
|
||||||
|
* degrades to the old position-only behaviour rather than throwing.
|
||||||
|
*/
|
||||||
|
let positionTypePermissionResolver: (positionTypeKey: string) => string[] = () =>
|
||||||
|
[];
|
||||||
|
|
||||||
|
export function setPositionTypePermissionResolver(
|
||||||
|
resolver: (positionTypeKey: string) => string[],
|
||||||
|
): void {
|
||||||
|
positionTypePermissionResolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat permission keys from JWT / session user: roles, position permissions,
|
||||||
|
* and the grants held by each position's TYPE.
|
||||||
|
*/
|
||||||
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] {
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
|
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
const addTypePermissions = (positionType: PositionTypeLike | null | undefined) => {
|
||||||
|
if (!positionType?.key) return;
|
||||||
|
for (const key of positionTypePermissionResolver(positionType.key)) {
|
||||||
|
keys.add(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for (const p of user.permissions ?? []) {
|
for (const p of user.permissions ?? []) {
|
||||||
if (p.key) keys.add(p.key);
|
if (p.key) keys.add(p.key);
|
||||||
}
|
}
|
||||||
@@ -63,6 +92,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
|||||||
for (const p of pos.permissions ?? []) {
|
for (const p of pos.permissions ?? []) {
|
||||||
if (p.key) keys.add(p.key);
|
if (p.key) keys.add(p.key);
|
||||||
}
|
}
|
||||||
|
addTypePermissions(pos.positionType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...keys];
|
return [...keys];
|
||||||
@@ -71,6 +101,7 @@ export function collectPermissionKeys(user: MeLikeUser | null | undefined): stri
|
|||||||
for (const p of employee.position?.permissions ?? []) {
|
for (const p of employee.position?.permissions ?? []) {
|
||||||
if (p.key) keys.add(p.key);
|
if (p.key) keys.add(p.key);
|
||||||
}
|
}
|
||||||
|
addTypePermissions(employee.position?.positionType);
|
||||||
for (const delegated of employee.delegatedPositions ?? []) {
|
for (const delegated of employee.delegatedPositions ?? []) {
|
||||||
for (const p of delegated.permissions ?? []) {
|
for (const p of delegated.permissions ?? []) {
|
||||||
if (p.key) keys.add(p.key);
|
if (p.key) keys.add(p.key);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { setPositionTypePermissionResolver } from './freight-permission.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions granted to a position TYPE (`iam.position_type_permissions`).
|
||||||
|
*
|
||||||
|
* A position type is the platform's notion of a role, and positions created
|
||||||
|
* through the admin UI carry their grants there rather than on the position
|
||||||
|
* itself. The JWT only ever snapshots DIRECT position permissions, so those
|
||||||
|
* grants are invisible to `collectPermissionKeys` — staff on such a position
|
||||||
|
* resolve to zero permissions and every permission-gated route rejects them.
|
||||||
|
*
|
||||||
|
* The permission checks (`hasFreightPermission`, `FreightPermissionGuard`) are
|
||||||
|
* synchronous and sit on the request path, so the mapping is held in memory and
|
||||||
|
* refreshed periodically rather than queried per request. The dataset is tiny
|
||||||
|
* (tens of types, a few hundred rows), so a full reload is cheaper than any
|
||||||
|
* incremental scheme.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PositionTypePermissionsCache implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(PositionTypePermissionsCache.name);
|
||||||
|
|
||||||
|
/** position_type key → permission keys. Empty until the first load lands. */
|
||||||
|
private byPositionTypeKey = new Map<string, string[]>();
|
||||||
|
|
||||||
|
// ponytail: fixed 5-min refresh, no invalidation hook. A permission granted
|
||||||
|
// in the admin UI takes up to one interval to reach the guards. Wire the
|
||||||
|
// grant mutation to call `refresh()` if that lag ever matters.
|
||||||
|
private static readonly REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.refresh();
|
||||||
|
// Hand the lookup to the permission utils, whose checks are synchronous and
|
||||||
|
// therefore cannot query IAM themselves.
|
||||||
|
setPositionTypePermissionResolver((positionTypeKey) =>
|
||||||
|
this.get(positionTypeKey),
|
||||||
|
);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void this.refresh();
|
||||||
|
}, PositionTypePermissionsCache.REFRESH_INTERVAL_MS);
|
||||||
|
// Never hold the process open for a cache refresh.
|
||||||
|
timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permission keys for a position-type key ([] when unknown/not loaded). */
|
||||||
|
get(positionTypeKey: string | undefined | null): string[] {
|
||||||
|
if (!positionTypeKey) return [];
|
||||||
|
return this.byPositionTypeKey.get(positionTypeKey) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the whole mapping. Failures keep the previous snapshot in place. */
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows: { position_type_key: string; permission_key: string }[] =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT pt.key AS position_type_key, perm.key AS permission_key
|
||||||
|
FROM iam.position_type_permissions ptp
|
||||||
|
JOIN iam.position_types pt ON pt.id = ptp.position_type_id
|
||||||
|
JOIN iam.permissions perm ON perm.id = ptp.permission_id`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = new Map<string, string[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.position_type_key || !row.permission_key) continue;
|
||||||
|
const keys = next.get(row.position_type_key);
|
||||||
|
if (keys) keys.push(row.permission_key);
|
||||||
|
else next.set(row.position_type_key, [row.permission_key]);
|
||||||
|
}
|
||||||
|
this.byPositionTypeKey = next;
|
||||||
|
} catch (err) {
|
||||||
|
// iam schema unreachable — keep serving the previous snapshot rather than
|
||||||
|
// dropping every type-derived permission and locking staff out.
|
||||||
|
this.logger.warn(
|
||||||
|
`Position-type permission refresh failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,10 +35,57 @@ export class FreightMeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
|
||||||
|
* A position type is the platform's notion of a role, and admin-created
|
||||||
|
* positions carry their grants there rather than on the position itself — but
|
||||||
|
* the JWT only ever snapshots direct position permissions. Without this, staff
|
||||||
|
* on such a position resolve to zero permissions and every permission-gated
|
||||||
|
* route rejects them (this is what locked GL officers out of their clearance
|
||||||
|
* detail pages). Resolved live from IAM, same as the position type above.
|
||||||
|
*/
|
||||||
|
private async lookupPositionTypePermissions(
|
||||||
|
positionId: string | undefined,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!positionId) return [];
|
||||||
|
try {
|
||||||
|
const rows: { key: string }[] = await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT perm.key
|
||||||
|
FROM iam.positions p
|
||||||
|
JOIN iam.position_type_permissions ptp
|
||||||
|
ON ptp.position_type_id = p.position_type_id
|
||||||
|
JOIN iam.permissions perm ON perm.id = ptp.permission_id
|
||||||
|
WHERE p.id = $1`,
|
||||||
|
[positionId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.key).filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return []; // iam schema unreachable — degrade to position-only permissions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getEnrichedProfile(user: TCurrentUser) {
|
async getEnrichedProfile(user: TCurrentUser) {
|
||||||
const positionType = await this.lookupPositionType(
|
const positionId = user.employee?.position?.id;
|
||||||
user.employee?.position?.id,
|
const [positionType, positionTypePermissionKeys] = await Promise.all([
|
||||||
|
this.lookupPositionType(positionId),
|
||||||
|
this.lookupPositionTypePermissions(positionId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge the type-level grants into the position's own permission list so
|
||||||
|
// BOTH consumers see them: `collectPermissionKeys` below, and the
|
||||||
|
// backoffice's `getPermissionKeys`, which walks this same nested array.
|
||||||
|
const positionPermissions = [
|
||||||
|
...(user.employee?.position?.permissions ?? []),
|
||||||
|
];
|
||||||
|
const seenPermissionKeys = new Set(
|
||||||
|
positionPermissions.map((p) => p?.key).filter(Boolean),
|
||||||
);
|
);
|
||||||
|
for (const key of positionTypePermissionKeys) {
|
||||||
|
if (!seenPermissionKeys.has(key)) {
|
||||||
|
seenPermissionKeys.add(key);
|
||||||
|
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const employee = user.employee
|
const employee = user.employee
|
||||||
? [
|
? [
|
||||||
@@ -56,7 +103,7 @@ export class FreightMeService {
|
|||||||
name: user.employee.position.name,
|
name: user.employee.position.name,
|
||||||
isDelegate: user.employee.position.isDelegate,
|
isDelegate: user.employee.position.isDelegate,
|
||||||
parentPositionId: user.employee.position.parentPositionId,
|
parentPositionId: user.employee.position.parentPositionId,
|
||||||
permissions: user.employee.position.permissions ?? [],
|
permissions: positionPermissions,
|
||||||
positionType,
|
positionType,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -65,7 +112,15 @@ export class FreightMeService {
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const permissionKeys = collectPermissionKeys(user);
|
// `collectPermissionKeys` reads the raw token (position-level only), so
|
||||||
|
// union the type-level grants in — the backoffice prefers this flat list
|
||||||
|
// over the nested array and would otherwise still see none of them.
|
||||||
|
const permissionKeys = [
|
||||||
|
...new Set([
|
||||||
|
...collectPermissionKeys(user),
|
||||||
|
...positionTypePermissionKeys,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@@ -90,6 +90,21 @@ export class BookingInvoiceService {
|
|||||||
return this.billing.generateInvoice(input);
|
return this.billing.generateInvoice(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the booking's open PREPAID invoice, if any — used when a
|
||||||
|
* changes-requested resubmit restates the cargo, so the re-priced booking can
|
||||||
|
* be re-invoiced. Throws when the invoice already has payments recorded
|
||||||
|
* (cargo must not change out from under recorded money).
|
||||||
|
*/
|
||||||
|
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
|
||||||
|
const existing = await this.billing.findPayable(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
bookingId,
|
||||||
|
"PREPAID",
|
||||||
|
);
|
||||||
|
if (existing) await this.billing.cancelInvoice(existing.id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React to a booking invoice being paid — the settlement branch point. Per-type
|
* React to a booking invoice being paid — the settlement branch point. Per-type
|
||||||
* reactions live here (not in the payment process): each invoice type advances
|
* reactions live here (not in the payment process): each invoice type advances
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ContractBookingService } from './contract-booking.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
|
||||||
|
* for the cargo itself to change, so a completion payload that restates
|
||||||
|
* containers must cancel the unpaid invoice, wipe the persisted cargo and
|
||||||
|
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
|
||||||
|
* payload without cargo keeps the day-only resubmit behavior.
|
||||||
|
*/
|
||||||
|
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
|
||||||
|
const CONTRACT = {
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-1',
|
||||||
|
contractKind: 'GENERAL',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
customsClearingEnabled: false,
|
||||||
|
contractValidUntil: null,
|
||||||
|
cargoScope: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookingWithCargo = () => ({
|
||||||
|
id: 'b-1',
|
||||||
|
contractId: 'c-1',
|
||||||
|
reference: 'BKG-1',
|
||||||
|
status: 'OPERATION_CHANGES_REQUESTED',
|
||||||
|
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
|
||||||
|
cargoTotalWeightVgm: 80,
|
||||||
|
originYardId: 'y-o',
|
||||||
|
destinationYardId: 'y-d',
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
const bookingsRepository = {
|
||||||
|
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
|
||||||
|
deleteContainers: jest.fn().mockResolvedValue(undefined),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const invoiceService = {
|
||||||
|
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const trainSchedulingService = {
|
||||||
|
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const contractsRepository = {
|
||||||
|
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
|
||||||
|
};
|
||||||
|
const service = new ContractBookingService(
|
||||||
|
contractsRepository as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
{} as never, // bookingPricingService
|
||||||
|
{} as never, // consolidationService
|
||||||
|
{} as never, // containerTypesService
|
||||||
|
{} as never, // ruleEngineService
|
||||||
|
{} as never, // milestoneService
|
||||||
|
invoiceService as never,
|
||||||
|
{} as never, // bookingNotifier
|
||||||
|
{} as never, // dataSource
|
||||||
|
trainSchedulingService as never,
|
||||||
|
{} as never, // bookingBatchService
|
||||||
|
{} as never, // bookingTransitionService
|
||||||
|
);
|
||||||
|
return { service, bookingsRepository, invoiceService };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both paths dead-end into a downstream private assert we replace with a
|
||||||
|
// sentinel — which path threw tells us which branch the resubmit took.
|
||||||
|
const SENTINEL = new Error('reached-branch');
|
||||||
|
|
||||||
|
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
|
||||||
|
const { service, bookingsRepository, invoiceService } = makeService();
|
||||||
|
// First gate inside the fresh-completion (!hasCargo) path.
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as never as { assertWithinQuantityCap: () => Promise<void> },
|
||||||
|
'assertWithinQuantityCap',
|
||||||
|
)
|
||||||
|
.mockRejectedValue(SENTINEL);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeUnderContract('c-1', 'b-1', {
|
||||||
|
scheduledDate: new Date().toISOString(),
|
||||||
|
containers: [{ containerSize: '20FT', quantity: 2 }],
|
||||||
|
} as never),
|
||||||
|
).rejects.toBe(SENTINEL);
|
||||||
|
|
||||||
|
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
|
||||||
|
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||||
|
cargoTotalWeightVgm: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
|
||||||
|
const { service, bookingsRepository, invoiceService } = makeService();
|
||||||
|
// First call inside the day-only (hasCargo) resubmit path.
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as never as {
|
||||||
|
assertPersistedContainersAvailable: () => Promise<void>;
|
||||||
|
},
|
||||||
|
'assertPersistedContainersAvailable',
|
||||||
|
)
|
||||||
|
.mockRejectedValue(SENTINEL);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.completeUnderContract('c-1', 'b-1', {
|
||||||
|
scheduledDate: new Date().toISOString(),
|
||||||
|
} as never),
|
||||||
|
).rejects.toBe(SENTINEL);
|
||||||
|
|
||||||
|
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
|
||||||
|
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -692,11 +692,30 @@ export class ContractBookingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const freightType = contract.freightType;
|
const freightType = contract.freightType;
|
||||||
const hasCargo =
|
let hasCargo =
|
||||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||||
Number(booking.cargoTotalWeightVgm) > 0;
|
Number(booking.cargoTotalWeightVgm) > 0;
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Operations may return a booking asking for the CARGO to change (fewer or
|
||||||
|
// more containers), not just the day. A resubmit whose payload restates the
|
||||||
|
// cargo therefore starts the completion over: cancel the unpaid invoice
|
||||||
|
// first (it throws if money is already recorded — cargo must not change
|
||||||
|
// under a paid invoice), then wipe the persisted cargo so the fresh-
|
||||||
|
// completion path below re-persists, re-prices and re-invoices from the
|
||||||
|
// payload. A resubmit without cargo keeps today's day-only behavior.
|
||||||
|
const restatesCargo = Boolean(
|
||||||
|
dto.containers?.length || dto.bulkLines?.length,
|
||||||
|
);
|
||||||
|
if (hasCargo && restatesCargo) {
|
||||||
|
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
|
||||||
|
await this.bookingsRepository.deleteContainers(booking.id);
|
||||||
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
cargoTotalWeightVgm: 0,
|
||||||
|
} as never);
|
||||||
|
hasCargo = false;
|
||||||
|
}
|
||||||
|
|
||||||
// EXPORT rides whole or not at all (no split concept): the chosen day must
|
// EXPORT rides whole or not at all (no split concept): the chosen day must
|
||||||
// have a single open train that carries the whole booking. First completion
|
// have a single open train that carries the whole booking. First completion
|
||||||
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
|
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
|
||||||
|
|||||||
@@ -947,7 +947,20 @@ export const POSITION_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.customers.verify,
|
FREIGHT_PERMS.customers.verify,
|
||||||
FREIGHT_PERMS.customers.deactivate,
|
FREIGHT_PERMS.customers.deactivate,
|
||||||
]),
|
]),
|
||||||
director: dedupe([...ROLE_PERMISSION_PRESETS.director]),
|
// Director additionally manages train scheduling + rail fleet (same block the
|
||||||
|
// operation officer/chief hold), on top of the approval-chain role preset.
|
||||||
|
director: dedupe([
|
||||||
|
...ROLE_PERMISSION_PRESETS.director,
|
||||||
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
|
FREIGHT_PERMS.trainScheduling.create,
|
||||||
|
FREIGHT_PERMS.trainScheduling.update,
|
||||||
|
FREIGHT_PERMS.trainScheduling.cancel,
|
||||||
|
FREIGHT_PERMS.trainScheduling.reschedule,
|
||||||
|
FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
FREIGHT_PERMS.fleet.manage,
|
||||||
|
...FLEET_GRANULAR_KEYS,
|
||||||
|
]),
|
||||||
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
||||||
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),
|
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),
|
||||||
djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]),
|
djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]),
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ interface AuthEmployeePosition {
|
|||||||
permissions?: AuthPermission[];
|
permissions?: AuthPermission[];
|
||||||
/** Some IAM payloads nest the position record instead of flattening its key. */
|
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||||
position?: { id?: string; key?: string; name?: LocaleText };
|
position?: { id?: string; key?: string; name?: LocaleText };
|
||||||
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
|
positionType?: {
|
||||||
|
id?: string;
|
||||||
|
key?: string;
|
||||||
|
name?: LocaleText;
|
||||||
|
/** Grants held by the TYPE — where admin-created positions keep theirs. */
|
||||||
|
permissions?: AuthPermission[];
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthEmployeeRecord {
|
interface AuthEmployeeRecord {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DateInput } from "@mantine/dates";
|
||||||
import { AlertTriangle, Send } from "lucide-react";
|
import { AlertTriangle, Pencil, Send } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
@@ -16,6 +16,9 @@ export interface BookingChangesRequestedAlertProps {
|
|||||||
scheduledDate?: string | null;
|
scheduledDate?: string | null;
|
||||||
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
|
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
|
||||||
canResubmit: boolean;
|
canResubmit: boolean;
|
||||||
|
/** Completion-form route for editing the cargo before resubmitting —
|
||||||
|
* rendered only for resubmit-capable users when provided. */
|
||||||
|
editHref?: string;
|
||||||
onResubmitted?: () => void;
|
onResubmitted?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ export function BookingChangesRequestedAlert({
|
|||||||
note,
|
note,
|
||||||
scheduledDate,
|
scheduledDate,
|
||||||
canResubmit,
|
canResubmit,
|
||||||
|
editHref,
|
||||||
onResubmitted,
|
onResubmitted,
|
||||||
}: BookingChangesRequestedAlertProps) {
|
}: BookingChangesRequestedAlertProps) {
|
||||||
const [day, setDay] = useState<Date | null>(
|
const [day, setDay] = useState<Date | null>(
|
||||||
@@ -122,6 +126,18 @@ export function BookingChangesRequestedAlert({
|
|||||||
>
|
>
|
||||||
Resubmit to Operations
|
Resubmit to Operations
|
||||||
</Button>
|
</Button>
|
||||||
|
{editHref ? (
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to={editHref}
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<Pencil size={15} />}
|
||||||
|
>
|
||||||
|
Edit cargo & resubmit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -342,6 +342,14 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
|||||||
for (const p of pos.permissions ?? []) {
|
for (const p of pos.permissions ?? []) {
|
||||||
if (p.key) keys.add(p.key);
|
if (p.key) keys.add(p.key);
|
||||||
}
|
}
|
||||||
|
// Positions created through the admin UI keep their grants on the
|
||||||
|
// position TYPE, not the position — miss these and such staff resolve to
|
||||||
|
// zero permissions and every gated route rejects them. `/api/me` folds
|
||||||
|
// them into the position's permission list, but older payloads may still
|
||||||
|
// carry them separately.
|
||||||
|
for (const p of pos.positionType?.permissions ?? []) {
|
||||||
|
if (p.key) keys.add(p.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...keys];
|
return [...keys];
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export default function ContractClearanceDetailPage() {
|
|||||||
note={clearance.linkedBookingReviewNote}
|
note={clearance.linkedBookingReviewNote}
|
||||||
scheduledDate={clearance.linkedBookingScheduledDate}
|
scheduledDate={clearance.linkedBookingScheduledDate}
|
||||||
canResubmit={canResubmitBooking}
|
canResubmit={canResubmitBooking}
|
||||||
|
editHref={`/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}`}
|
||||||
onResubmitted={() => {
|
onResubmitted={() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
void refetchContract();
|
void refetchContract();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { AlertCircle, Send, XCircle } from "lucide-react";
|
import { AlertCircle, Pencil, Send, XCircle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export function ChangesRequestedView({
|
|||||||
|
|
||||||
<StatusHero booking={booking}>
|
<StatusHero booking={booking}>
|
||||||
{booking.latestChangeRequestNote ? (
|
{booking.latestChangeRequestNote ? (
|
||||||
<ActionRequiredBanner title="Review the requested changes, then resubmit.">
|
<ActionRequiredBanner title="Review the requested changes, update the booking if needed, then resubmit.">
|
||||||
{booking.latestChangeRequestNote}
|
{booking.latestChangeRequestNote}
|
||||||
</ActionRequiredBanner>
|
</ActionRequiredBanner>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -108,8 +108,25 @@ export function ChangesRequestedView({
|
|||||||
<Text fz="12.5px" c="#6B7C8E" mb="sm">
|
<Text fz="12.5px" c="#6B7C8E" mb="sm">
|
||||||
Update the documents for this booking, then resubmit for review.
|
Update the documents for this booking, then resubmit for review.
|
||||||
Replace any that changed and attach any that are still required.
|
Replace any that changed and attach any that are still required.
|
||||||
|
Need to change the cargo itself — containers, route, schedule or
|
||||||
|
other details? Edit the booking first, then come back and
|
||||||
|
resubmit.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
radius={10}
|
||||||
|
variant="default"
|
||||||
|
leftSection={<Pencil size={16} />}
|
||||||
|
onClick={() => navigate(`/bookings/${booking.id}/edit`)}
|
||||||
|
styles={{
|
||||||
|
root: { height: 46 },
|
||||||
|
label: { fontSize: 14, fontWeight: 700 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit booking details
|
||||||
|
</Button>
|
||||||
|
|
||||||
<ResubmitDocuments flow={flow} />
|
<ResubmitDocuments flow={flow} />
|
||||||
|
|
||||||
{flow.validationError && (
|
{flow.validationError && (
|
||||||
|
|||||||
Reference in New Issue
Block a user