mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
Add freight demo data seeder and permissions management
- Introduced `DemoFreightDataSeeder` to seed demo freight data including wagons, approval rules, and staff users. - Added `seed:freight-demo` script to `package.json` for easy execution. - Updated permissions for operations officer and added permission checks in various components. - Enhanced sidebar and booking actions to respect user permissions.
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
|
||||||
|
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
|
|||||||
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||||
@@ -121,6 +122,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
PricingDataSeeder,
|
PricingDataSeeder,
|
||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
|
DemoFreightDataSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
@@ -133,6 +135,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
|
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@@ -144,5 +147,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.demoBookingsSeeder.run();
|
await this.demoBookingsSeeder.run();
|
||||||
await this.pricingDataSeeder.run();
|
await this.pricingDataSeeder.run();
|
||||||
await this.fileUploadSettingsSeeder.run();
|
await this.fileUploadSettingsSeeder.run();
|
||||||
|
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||||
|
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||||
|
await this.demoFreightDataSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ export class TrainSchedulingController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("bookable-schedules")
|
@Get("bookable-schedules")
|
||||||
// @TrainSchedulingView()
|
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||||
|
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "OPEN same-route schedules a new booking can target",
|
summary: "OPEN same-route schedules a new booking can target",
|
||||||
})
|
})
|
||||||
|
|||||||
28
apps/edr-freight-api/src/scripts/seed-freight-demo.ts
Normal file
28
apps/edr-freight-api/src/scripts/seed-freight-demo.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../app.module';
|
||||||
|
import { DemoFreightDataSeeder } from '../seed/demo-freight-data.seeder';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeder = app.get(DemoFreightDataSeeder);
|
||||||
|
await seeder.run();
|
||||||
|
console.log('Freight demo data seeded (wagons, approval rules, staff users).');
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Freight demo seed failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
180
apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
Normal file
180
apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { WagonStatus } from '@edr/types';
|
||||||
|
import { hashPassword } from '@tria-plc/api-common/utils/argon';
|
||||||
|
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||||
|
import {
|
||||||
|
Employee,
|
||||||
|
Organization,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
UserCredential,
|
||||||
|
UserRole,
|
||||||
|
} from '@tria-plc/iamapi-common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||||
|
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||||
|
import { ApprovalRule } from '../modules/rule-engine/entities/approval-rule.entity';
|
||||||
|
import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rules.defaults';
|
||||||
|
|
||||||
|
const EDR_ORG_KEY = 'edr_freight';
|
||||||
|
const MIN_WAGONS_PER_TYPE = 100;
|
||||||
|
|
||||||
|
/** The four demo staff users, each mapped to a seeded freight role. */
|
||||||
|
const DEMO_STAFF_USERS = [
|
||||||
|
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
|
||||||
|
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
|
||||||
|
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||||
|
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot demo data: at least 100 wagons per wagon type, the default approval
|
||||||
|
* chains, and four staff users with distinct permissions. Every block guards on
|
||||||
|
* an "is it already populated?" check, so this is safe to run on every boot and
|
||||||
|
* does nothing once the data exists.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DemoFreightDataSeeder {
|
||||||
|
private readonly logger = new Logger(DemoFreightDataSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.seedWagons(manager);
|
||||||
|
await this.seedApprovalRules(manager);
|
||||||
|
await this.seedStaffUsers(manager);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure every wagon type has at least MIN_WAGONS_PER_TYPE wagons. */
|
||||||
|
private async seedWagons(manager: EntityManager) {
|
||||||
|
const wagonTypeRepo = manager.getRepository(WagonType);
|
||||||
|
const wagonRepo = manager.getRepository(Wagon);
|
||||||
|
|
||||||
|
const wagonTypes = await wagonTypeRepo.find();
|
||||||
|
if (wagonTypes.length === 0) {
|
||||||
|
this.logger.warn('No wagon types found; skipping wagon seed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of wagonTypes) {
|
||||||
|
const existing = await wagonRepo.count({ where: { wagonTypeId: type.id } });
|
||||||
|
if (existing >= MIN_WAGONS_PER_TYPE) {
|
||||||
|
this.logger.log(
|
||||||
|
`Wagon type ${type.code} already has ${existing} wagons; skipping`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCreate = MIN_WAGONS_PER_TYPE - existing;
|
||||||
|
const tare = Number(type.tareWeightTons ?? 20);
|
||||||
|
const maxPayload = Number(type.capacityTons ?? 60);
|
||||||
|
const rows = Array.from({ length: toCreate }, (_, i) => {
|
||||||
|
const seq = existing + i + 1;
|
||||||
|
return wagonRepo.create({
|
||||||
|
wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`,
|
||||||
|
wagonTypeId: type.id,
|
||||||
|
tareWeight: tare,
|
||||||
|
maxPayloadWeight: maxPayload,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await wagonRepo.save(rows);
|
||||||
|
this.logger.log(`Seeded ${toCreate} wagons for type ${type.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed the default approval chains when the table is empty. */
|
||||||
|
private async seedApprovalRules(manager: EntityManager) {
|
||||||
|
const repo = manager.getRepository(ApprovalRule);
|
||||||
|
const count = await repo.count();
|
||||||
|
if (count > 0) {
|
||||||
|
this.logger.log(`Approval rules already populated (${count}); skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await repo.save(DEFAULT_APPROVAL_RULE_ROWS.map((row) => repo.create(row)));
|
||||||
|
this.logger.log(`Seeded ${DEFAULT_APPROVAL_RULE_ROWS.length} approval rules`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the four demo staff users with their roles (idempotent per email). */
|
||||||
|
private async seedStaffUsers(manager: EntityManager) {
|
||||||
|
const organization = await manager.getRepository(Organization).findOne({
|
||||||
|
where: { key: EDR_ORG_KEY },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
if (!organization) {
|
||||||
|
this.logger.warn(`Missing organization ${EDR_ORG_KEY}; skipping staff users`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRepo = manager.getRepository(Role);
|
||||||
|
const userRepo = manager.getRepository(User);
|
||||||
|
const credentialRepo = manager.getRepository(UserCredential);
|
||||||
|
const userRoleRepo = manager.getRepository(UserRole);
|
||||||
|
const employeeRepo = manager.getRepository(Employee);
|
||||||
|
|
||||||
|
const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678';
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
|
for (const staff of DEMO_STAFF_USERS) {
|
||||||
|
const role = await roleRepo.findOne({
|
||||||
|
where: { key: staff.roleKey },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
if (!role) {
|
||||||
|
this.logger.warn(`Missing role ${staff.roleKey}; skipping ${staff.email}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await userRepo.findOne({
|
||||||
|
where: { email: staff.email },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
user = await userRepo.save(
|
||||||
|
userRepo.create({
|
||||||
|
email: staff.email,
|
||||||
|
username: staff.username,
|
||||||
|
name: { en: staff.username },
|
||||||
|
isActive: true,
|
||||||
|
hasSetPassword: true,
|
||||||
|
status: EUserStatus.ACCEPTED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Seeded staff user ${staff.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCredential = await credentialRepo.exists({
|
||||||
|
where: { userId: user.id, isActive: true },
|
||||||
|
});
|
||||||
|
if (!hasCredential) {
|
||||||
|
await credentialRepo.insert({
|
||||||
|
userId: user.id,
|
||||||
|
password: hashedPassword,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await userRoleRepo.upsert(
|
||||||
|
{ userId: user.id, roleId: role.id, organizationId: organization.id },
|
||||||
|
{ conflictPaths: { userId: true, roleId: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasEmployee = await employeeRepo.exists({
|
||||||
|
where: { userId: user.id, organizationId: organization.id, isCurrent: true },
|
||||||
|
});
|
||||||
|
if (!hasEmployee) {
|
||||||
|
await employeeRepo.insert({
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: organization.id,
|
||||||
|
isCurrent: true,
|
||||||
|
name: { en: staff.username },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('Ensured demo staff users (marketing@, operations@, director@, ceo@)');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,6 +212,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
|||||||
name: { en: "EDR Line Staff" },
|
name: { en: "EDR Line Staff" },
|
||||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "edr_operations_officer",
|
||||||
|
name: { en: "EDR Operations Officer" },
|
||||||
|
permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "edr_director",
|
key: "edr_director",
|
||||||
name: { en: "EDR Director" },
|
name: { en: "EDR Director" },
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ const allRuleEngineViewKeys = () =>
|
|||||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
||||||
|
|
||||||
export const ROLE_PERMISSION_PRESETS = {
|
export const ROLE_PERMISSION_PRESETS = {
|
||||||
|
// Marketing / line staff: drives a booking from intake through line-staff
|
||||||
|
// approval and contract generation/signing — i.e. until the contract is ready
|
||||||
|
// and signed. No director/CEO approval, no scheduling, no operations.
|
||||||
lineStaff: [
|
lineStaff: [
|
||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
FREIGHT_PERMS.bookings.staffAccept,
|
FREIGHT_PERMS.bookings.staffAccept,
|
||||||
@@ -129,6 +132,12 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
FREIGHT_PERMS.bookings.rejectApproval,
|
FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
FREIGHT_PERMS.bookings.cancel,
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
|
...allRuleEngineViewKeys(),
|
||||||
|
],
|
||||||
|
// Operations Officer: train scheduling + wagon allocation + transit/complete.
|
||||||
|
operationsOfficer: [
|
||||||
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
FREIGHT_PERMS.bookings.operations,
|
||||||
FREIGHT_PERMS.trainScheduling.view,
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
FREIGHT_PERMS.trainScheduling.manage,
|
FREIGHT_PERMS.trainScheduling.manage,
|
||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
@@ -147,8 +156,15 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
...allRuleEngineViewKeys(),
|
...allRuleEngineViewKeys(),
|
||||||
],
|
],
|
||||||
finance: [FREIGHT_PERMS.bookings.view],
|
finance: [FREIGHT_PERMS.bookings.view],
|
||||||
|
// Marketing handles intake through contract (same as line staff here).
|
||||||
marketing: [
|
marketing: [
|
||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
|
FREIGHT_PERMS.bookings.staffAccept,
|
||||||
|
FREIGHT_PERMS.bookings.requestChanges,
|
||||||
|
FREIGHT_PERMS.bookings.reject,
|
||||||
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
|
FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
FREIGHT_PERMS.bookings.generateContract,
|
FREIGHT_PERMS.bookings.generateContract,
|
||||||
FREIGHT_PERMS.bookings.signStaff,
|
FREIGHT_PERMS.bookings.signStaff,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa
|
|||||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions";
|
||||||
|
import { RequirePermission } from "./components/auth/RequirePermission";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
|
|
||||||
@@ -80,11 +82,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
href: "/dashboard/operations/train-scheduling-v2",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
|
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Batch Board",
|
label: "Batch Board",
|
||||||
href: "/dashboard/operations/batch-board",
|
href: "/dashboard/operations/batch-board",
|
||||||
icon: <LayoutGrid />,
|
icon: <LayoutGrid />,
|
||||||
|
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -196,18 +200,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasPermission = (
|
/** Keep only items the user is permitted to see; drop now-empty sections. */
|
||||||
|
const filterSidebarByPermission = (
|
||||||
|
sections: SidebarSection[],
|
||||||
user: ReturnType<typeof useAuth>["user"],
|
user: ReturnType<typeof useAuth>["user"],
|
||||||
key: string,
|
): SidebarSection[] => {
|
||||||
) => {
|
const itemAllowed = (item: SidebarItem): boolean => {
|
||||||
if (!user) return false;
|
if (!item.permission) return true;
|
||||||
if (user.permissions?.some((p) => p.key === key)) return true;
|
const keys = Array.isArray(item.permission)
|
||||||
|
? item.permission
|
||||||
|
: [item.permission];
|
||||||
|
return keys.some((key) => hasFreightPermission(user, key));
|
||||||
|
};
|
||||||
|
|
||||||
return (user.employee ?? []).some((emp) =>
|
return sections
|
||||||
(emp.positions ?? []).some((pos) =>
|
.map((section) => ({
|
||||||
(pos.permissions ?? []).some((p) => p.key === key),
|
...section,
|
||||||
),
|
items: section.items.filter(itemAllowed),
|
||||||
);
|
}))
|
||||||
|
.filter((section) => section.items.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DashboardShell = () => {
|
const DashboardShell = () => {
|
||||||
@@ -217,7 +228,10 @@ const DashboardShell = () => {
|
|||||||
|
|
||||||
const demoItems: SidebarItem[] = [];
|
const demoItems: SidebarItem[] = [];
|
||||||
|
|
||||||
const sidebarSections = buildSidebarSections(demoItems);
|
const sidebarSections = filterSidebarByPermission(
|
||||||
|
buildSidebarSections(demoItems),
|
||||||
|
user,
|
||||||
|
);
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -271,22 +285,45 @@ const App = () => {
|
|||||||
path="operations/train-scheduling"
|
path="operations/train-scheduling"
|
||||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="operations/batch-board" element={<BatchBoardPage />} />
|
<Route
|
||||||
|
path="operations/batch-board"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<BatchBoardPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/batch-board/:scheduleId"
|
path="operations/batch-board/:scheduleId"
|
||||||
element={<BatchScheduleDetailPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<BatchScheduleDetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2"
|
path="operations/train-scheduling-v2"
|
||||||
element={<TrainScheduleV2ListPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleV2ListPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2/:scheduleId"
|
path="operations/train-scheduling-v2/:scheduleId"
|
||||||
element={<TrainScheduleV2DetailPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleV2DetailPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||||
element={<TrainScheduleTrackPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainScheduleTrackPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="routes" element={<RoutesPage />} />
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
<Route path="locomotives" element={<FleetResourcePage />} />
|
<Route path="locomotives" element={<FleetResourcePage />} />
|
||||||
@@ -316,7 +353,11 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="configuration/train-scheduling-rules"
|
path="configuration/train-scheduling-rules"
|
||||||
element={<TrainSchedulingGlobalRulesPage />}
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||||
|
<TrainSchedulingGlobalRulesPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
|
||||||
|
interface RequirePermissionProps {
|
||||||
|
/** Permission key(s); access is granted if the user has ANY of them. */
|
||||||
|
permission: string | string[];
|
||||||
|
/** Where to send users who lack the permission. */
|
||||||
|
redirectTo?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-level guard: renders children only when the current user holds one of
|
||||||
|
* the given permissions, otherwise redirects (default: overview).
|
||||||
|
*/
|
||||||
|
export function RequirePermission({
|
||||||
|
permission,
|
||||||
|
redirectTo = "/dashboard/overview",
|
||||||
|
children,
|
||||||
|
}: RequirePermissionProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const keys = Array.isArray(permission) ? permission : [permission];
|
||||||
|
const allowed = keys.some((key) => hasPermission(user, key));
|
||||||
|
|
||||||
|
if (!allowed) return <Navigate to={redirectTo} replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { BookingActionsMenu } from "./BookingActionsMenu";
|
|||||||
import { SectionCard } from "./detail/SectionCard";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { canManageScheduling } from "@/lib/permissions";
|
||||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
@@ -19,9 +21,11 @@ interface BookingActionsToolbarProps {
|
|||||||
|
|
||||||
/** Detail-page actions: primary toolbar + downloads. */
|
/** Detail-page actions: primary toolbar + downloads. */
|
||||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const row = toBookingListRow(booking);
|
const row = toBookingListRow(booking);
|
||||||
const { status } = booking;
|
const { status } = booking;
|
||||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||||
|
const canAllocate = canManageScheduling(user);
|
||||||
|
|
||||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||||
const blob = await fn();
|
const blob = await fn();
|
||||||
@@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canAllocateBooking(booking) ? (
|
{canAllocate && canAllocateBooking(booking) ? (
|
||||||
<AllocateBookingWizard
|
<AllocateBookingWizard
|
||||||
booking={booking}
|
booking={booking}
|
||||||
opened={allocateOpen}
|
opened={allocateOpen}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface SidebarItem {
|
|||||||
href?: string;
|
href?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
children?: SidebarItem[];
|
children?: SidebarItem[];
|
||||||
|
/** Permission key(s) required to see this item; ANY grants access. */
|
||||||
|
permission?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarSection {
|
export interface SidebarSection {
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
|||||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||||
complete: FREIGHT_PERMS.bookings.operations,
|
complete: FREIGHT_PERMS.bookings.operations,
|
||||||
|
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
|
||||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export const FREIGHT_PERMS = {
|
|||||||
operations: "edr_freight_app:bookings:operations",
|
operations: "edr_freight_app:bookings:operations",
|
||||||
cancel: "edr_freight_app:bookings:cancel",
|
cancel: "edr_freight_app:bookings:cancel",
|
||||||
},
|
},
|
||||||
|
trainScheduling: {
|
||||||
|
view: "edr_freight_app:train_scheduling:view",
|
||||||
|
manage: "edr_freight_app:train_scheduling:manage",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||||
@@ -70,6 +74,14 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
|||||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canViewScheduling(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageScheduling(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage);
|
||||||
|
}
|
||||||
|
|
||||||
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string {
|
||||||
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`;
|
||||||
}
|
}
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -122,6 +122,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
typeorm:
|
||||||
|
specifier: ^0.3.30
|
||||||
|
version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@edr/eslint-config':
|
'@edr/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -177,9 +180,6 @@ importers:
|
|||||||
tsconfig-paths:
|
tsconfig-paths:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
typeorm:
|
|
||||||
specifier: ^0.3.30
|
|
||||||
version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.5.4
|
specifier: ^5.5.4
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -14446,7 +14446,7 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||||
|
|
||||||
'@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
'@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
||||||
@@ -14477,19 +14477,6 @@ snapshots:
|
|||||||
class-transformer: 0.5.1
|
class-transformer: 0.5.1
|
||||||
class-validator: 0.14.4
|
class-validator: 0.14.4
|
||||||
|
|
||||||
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
|
||||||
dependencies:
|
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
iterare: 1.2.1
|
|
||||||
reflect-metadata: 0.2.2
|
|
||||||
rxjs: 7.8.2
|
|
||||||
tslib: 2.8.1
|
|
||||||
optionalDependencies:
|
|
||||||
amqp-connection-manager: 5.0.0(amqplib@0.10.9)
|
|
||||||
amqplib: 0.10.9
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -14574,7 +14561,7 @@ snapshots:
|
|||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||||
|
|
||||||
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)':
|
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)':
|
||||||
@@ -18651,12 +18638,6 @@ snapshots:
|
|||||||
amqplib: 0.10.9
|
amqplib: 0.10.9
|
||||||
promise-breaker: 6.0.0
|
promise-breaker: 6.0.0
|
||||||
|
|
||||||
amqp-connection-manager@5.0.0(amqplib@0.10.9):
|
|
||||||
dependencies:
|
|
||||||
amqplib: 0.10.9
|
|
||||||
promise-breaker: 6.0.0
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
amqp-connection-manager@5.0.0(amqplib@2.0.1):
|
amqp-connection-manager@5.0.0(amqplib@2.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
amqplib: 2.0.1
|
amqplib: 2.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user