mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Merge pull request #1500 from Tria-plc/freight_feature/usermanagement
This commit is contained in:
@@ -109,6 +109,7 @@ import { ExportsModule } from "./modules/exports/exports.module";
|
||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||
import { TrainCrewModule } from "./modules/train-crew/train-crew.module";
|
||||
import { FuelModule } from "./modules/fuel/fuel.module";
|
||||
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
||||
import { ComplianceModule } from "./modules/compliance/compliance.module";
|
||||
@@ -252,6 +253,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
UserTradeAccessModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
TrainCrewModule,
|
||||
FuelModule,
|
||||
MaintenanceModule,
|
||||
ComplianceModule,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Roster of people assignable to a train (ITLMS Rolling Stock §1.2 crew
|
||||
* composition). Separate from freight.drivers, which registers road/last-mile
|
||||
* truck drivers and shares none of these fields.
|
||||
*
|
||||
* Role and nationality are stored as varchar rather than PG enums so adding a
|
||||
* crew role later is an application change, not a type migration. The partial
|
||||
* unique index keys on name + role — the roster has no employee number yet, so
|
||||
* that is the only identity available to block an accidental re-entry; it is
|
||||
* scoped to live rows so a soft-deleted member does not hold the name hostage.
|
||||
*/
|
||||
export class TrainCrewMembers3850000000000 implements MigrationInterface {
|
||||
name = 'TrainCrewMembers3850000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_crew_members (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
first_name varchar(100) NOT NULL,
|
||||
last_name varchar(100) NOT NULL,
|
||||
role varchar(32) NOT NULL,
|
||||
nationality varchar(16) NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT chk_train_crew_role CHECK (role IN (
|
||||
'TRAIN_DRIVER','FEDERAL_POLICE','TECHNICIAN','REEFER_TECHNICIAN',
|
||||
'HAZMAT_ESCORT','LASHING_INSPECTOR','LIVESTOCK_HANDLER'
|
||||
)),
|
||||
CONSTRAINT chk_train_crew_nationality CHECK (nationality IN (
|
||||
'ETHIOPIAN','DJIBOUTIAN'
|
||||
)),
|
||||
CONSTRAINT chk_train_crew_status CHECK (status IN (
|
||||
'ACTIVE','INACTIVE','SUSPENDED','ON_LEAVE'
|
||||
))
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_crew_members_role
|
||||
ON freight.train_crew_members (role)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_crew_members_nationality
|
||||
ON freight.train_crew_members (nationality)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_crew_members_status
|
||||
ON freight.train_crew_members (status)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_crew_members_is_active
|
||||
ON freight.train_crew_members (is_active)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_crew_members_name_role
|
||||
ON freight.train_crew_members (lower(first_name), lower(last_name), role)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_members`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import {
|
||||
TrainCrewNationality,
|
||||
TrainCrewRole,
|
||||
TrainCrewStatus,
|
||||
} from '../entities/train-crew-member.entity';
|
||||
|
||||
export class CreateTrainCrewMemberDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
lastName!: string;
|
||||
|
||||
@IsEnum(TrainCrewRole)
|
||||
role!: TrainCrewRole;
|
||||
|
||||
@IsEnum(TrainCrewNationality)
|
||||
nationality!: TrainCrewNationality;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TrainCrewStatus)
|
||||
status?: TrainCrewStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import {
|
||||
TrainCrewNationality,
|
||||
TrainCrewRole,
|
||||
TrainCrewStatus,
|
||||
} from '../entities/train-crew-member.entity';
|
||||
|
||||
/** Sortable columns. Whitelisted: the value is interpolated into ORDER BY. */
|
||||
export const TRAIN_CREW_SORT_FIELDS = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'role',
|
||||
'nationality',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
] as const;
|
||||
|
||||
export class QueryTrainCrewMemberDto {
|
||||
/** Matched against first and last name. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TrainCrewRole)
|
||||
role?: TrainCrewRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TrainCrewNationality)
|
||||
nationality?: TrainCrewNationality;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TrainCrewStatus)
|
||||
status?: TrainCrewStatus;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(TRAIN_CREW_SORT_FIELDS as unknown as string[])
|
||||
sortBy?: (typeof TRAIN_CREW_SORT_FIELDS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ASC', 'DESC'])
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateTrainCrewMemberDto } from './create-train-crew-member.dto';
|
||||
|
||||
export class UpdateTrainCrewMemberDto extends PartialType(CreateTrainCrewMemberDto) {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
|
||||
/**
|
||||
* On-board role a crew member is rostered for. Mirrors the crew composition
|
||||
* rules in ITLMS Rolling Stock §1.2: driving crew, the federal police security
|
||||
* detail, technical maintenance, and the four specialized cargo roles.
|
||||
*/
|
||||
export enum TrainCrewRole {
|
||||
TRAIN_DRIVER = 'TRAIN_DRIVER',
|
||||
FEDERAL_POLICE = 'FEDERAL_POLICE',
|
||||
TECHNICIAN = 'TECHNICIAN',
|
||||
REEFER_TECHNICIAN = 'REEFER_TECHNICIAN',
|
||||
HAZMAT_ESCORT = 'HAZMAT_ESCORT',
|
||||
LASHING_INSPECTOR = 'LASHING_INSPECTOR',
|
||||
LIVESTOCK_HANDLER = 'LIVESTOCK_HANDLER',
|
||||
}
|
||||
|
||||
/**
|
||||
* Employing country. Drives the territorial boundary in §1.1 — Djibouti train
|
||||
* drivers operate only on the Dire Dawa – Nagad segment — and the crewing
|
||||
* cases in §2 (Case 1 pairs 2 Ethiopian with 2 Djiboutian drivers).
|
||||
*/
|
||||
export enum TrainCrewNationality {
|
||||
ETHIOPIAN = 'ETHIOPIAN',
|
||||
DJIBOUTIAN = 'DJIBOUTIAN',
|
||||
}
|
||||
|
||||
export enum TrainCrewStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
SUSPENDED = 'SUSPENDED',
|
||||
ON_LEAVE = 'ON_LEAVE',
|
||||
}
|
||||
|
||||
/**
|
||||
* Roster of people assignable to a train. Distinct from `freight.drivers`,
|
||||
* which is the road/last-mile truck driver register (licences, vehicle types,
|
||||
* trip counts) — a train driver shares none of those fields.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'train_crew_members' })
|
||||
@Index(['role'])
|
||||
@Index(['nationality'])
|
||||
@Index(['status'])
|
||||
@Index(['isActive'])
|
||||
export class TrainCrewMember extends BaseEntity {
|
||||
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||
firstName!: string;
|
||||
|
||||
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||
lastName!: string;
|
||||
|
||||
@Column({ name: 'role', type: 'varchar', length: 32 })
|
||||
role!: TrainCrewRole;
|
||||
|
||||
@Column({ name: 'nationality', type: 'varchar', length: 16 })
|
||||
nationality!: TrainCrewNationality;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: 16,
|
||||
default: TrainCrewStatus.ACTIVE,
|
||||
})
|
||||
status!: TrainCrewStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
|
||||
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
|
||||
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
|
||||
import { TrainCrewService } from './train-crew.service';
|
||||
|
||||
@ApiTags('train-crew')
|
||||
@ApiBearerAuth()
|
||||
@Controller('train-crew')
|
||||
// Class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here would deny before the route's own key runs.
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.trainCrew.view,
|
||||
FREIGHT_PERMS.trainCrew.create,
|
||||
FREIGHT_PERMS.trainCrew.update,
|
||||
FREIGHT_PERMS.trainCrew.delete,
|
||||
])
|
||||
export class TrainCrewController {
|
||||
constructor(private readonly trainCrewService: TrainCrewService) {}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.trainCrew.create)
|
||||
@ApiOperation({ summary: 'Create a train crew member' })
|
||||
create(@Body() dto: CreateTrainCrewMemberDto) {
|
||||
return this.trainCrewService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List train crew members with filters' })
|
||||
findAll(@Query() query: QueryTrainCrewMemberDto) {
|
||||
return this.trainCrewService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a train crew member by id' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainCrewService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.trainCrew.update)
|
||||
@ApiOperation({ summary: 'Update a train crew member' })
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateTrainCrewMemberDto,
|
||||
) {
|
||||
return this.trainCrewService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.trainCrew.delete)
|
||||
@ApiOperation({ summary: 'Delete a train crew member' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainCrewService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TrainCrewMember } from './entities/train-crew-member.entity';
|
||||
import { TrainCrewController } from './train-crew.controller';
|
||||
import { TrainCrewService } from './train-crew.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TrainCrewMember])],
|
||||
providers: [TrainCrewService],
|
||||
controllers: [TrainCrewController],
|
||||
exports: [TrainCrewService],
|
||||
})
|
||||
export class TrainCrewModule {}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ILike, Repository } from 'typeorm';
|
||||
|
||||
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
|
||||
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
|
||||
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
|
||||
import { TrainCrewMember } from './entities/train-crew-member.entity';
|
||||
|
||||
const DEFAULT_LIMIT = 25;
|
||||
|
||||
@Injectable()
|
||||
export class TrainCrewService {
|
||||
constructor(
|
||||
@InjectRepository(TrainCrewMember)
|
||||
private readonly crewRepo: Repository<TrainCrewMember>,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateTrainCrewMemberDto): Promise<TrainCrewMember> {
|
||||
await this.assertNoDuplicate(dto.firstName, dto.lastName, dto.role);
|
||||
const member = this.crewRepo.create(dto);
|
||||
return this.crewRepo.save(member);
|
||||
}
|
||||
|
||||
async findAll(query: QueryTrainCrewMemberDto = {}): Promise<{
|
||||
data: TrainCrewMember[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const qb = this.crewRepo.createQueryBuilder('c');
|
||||
|
||||
if (query.search) {
|
||||
qb.andWhere('(c.firstName ILIKE :search OR c.lastName ILIKE :search)', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
if (query.role) qb.andWhere('c.role = :role', { role: query.role });
|
||||
if (query.nationality) {
|
||||
qb.andWhere('c.nationality = :nationality', { nationality: query.nationality });
|
||||
}
|
||||
if (query.status) qb.andWhere('c.status = :status', { status: query.status });
|
||||
if (query.isActive !== undefined) {
|
||||
qb.andWhere('c.isActive = :isActive', { isActive: query.isActive });
|
||||
}
|
||||
|
||||
// sortBy is whitelisted by QueryTrainCrewMemberDto's @IsIn before it lands here.
|
||||
const [data, total] = await qb
|
||||
.orderBy(`c.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TrainCrewMember> {
|
||||
const member = await this.crewRepo.findOne({ where: { id } });
|
||||
if (!member) {
|
||||
throw new NotFoundException(`Train crew member ${id} not found`);
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTrainCrewMemberDto): Promise<TrainCrewMember> {
|
||||
const member = await this.findById(id);
|
||||
|
||||
const firstName = dto.firstName ?? member.firstName;
|
||||
const lastName = dto.lastName ?? member.lastName;
|
||||
const role = dto.role ?? member.role;
|
||||
const identityChanged =
|
||||
firstName !== member.firstName ||
|
||||
lastName !== member.lastName ||
|
||||
role !== member.role;
|
||||
if (identityChanged) {
|
||||
await this.assertNoDuplicate(firstName, lastName, role, id);
|
||||
}
|
||||
|
||||
Object.assign(member, dto);
|
||||
return this.crewRepo.save(member);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.crewRepo.softDelete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The roster carries no employee number yet, so name + role is the only
|
||||
* identity available to catch an accidental re-entry of the same person.
|
||||
* Case-insensitive; `exceptId` skips the row being updated.
|
||||
*/
|
||||
private async assertNoDuplicate(
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
role: string,
|
||||
exceptId?: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.crewRepo.findOne({
|
||||
where: { firstName: ILike(firstName), lastName: ILike(lastName), role: role as never },
|
||||
});
|
||||
if (existing && existing.id !== exceptId) {
|
||||
throw new ConflictException(
|
||||
`Train crew member ${firstName} ${lastName} (${role}) already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1506,6 +1506,33 @@ export const EMPTY_RETURN_REQUEST_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
),
|
||||
];
|
||||
|
||||
// E'''. Train crew roster — the people assignable to a train (drivers, federal
|
||||
// police, technicians, specialized cargo crew) per ITLMS Rolling Stock 1.2.
|
||||
// Distinct from the `drivers` keys above, which gate the road/last-mile truck
|
||||
// driver register.
|
||||
export const TRAIN_CREW_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
"f5a00001-0001-4000-8000-000000000001",
|
||||
"edr_freight_app:train_crew:view",
|
||||
"View train crew members",
|
||||
),
|
||||
perm(
|
||||
"f5a00001-0001-4000-8000-000000000002",
|
||||
"edr_freight_app:train_crew:create",
|
||||
"Create train crew member",
|
||||
),
|
||||
perm(
|
||||
"f5a00001-0001-4000-8000-000000000003",
|
||||
"edr_freight_app:train_crew:update",
|
||||
"Update train crew member",
|
||||
),
|
||||
perm(
|
||||
"f5a00001-0001-4000-8000-000000000004",
|
||||
"edr_freight_app:train_crew:delete",
|
||||
"Delete train crew member",
|
||||
),
|
||||
];
|
||||
|
||||
// E'. Train-scheduling finer actions (augment existing view/manage)
|
||||
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm(
|
||||
@@ -1954,6 +1981,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
...PORT_TERMINAL_PERMISSIONS,
|
||||
...ADDITIONAL_CHARGE_PERMISSIONS,
|
||||
...EMPTY_RETURN_REQUEST_PERMISSIONS,
|
||||
...TRAIN_CREW_PERMISSIONS,
|
||||
...SCHEDULING_EXTRA_PERMISSIONS,
|
||||
...CONFIG_SETTINGS_PERMISSIONS,
|
||||
...STAFF_IAM_PERMISSIONS,
|
||||
@@ -2331,6 +2359,12 @@ export const FREIGHT_PERMS = {
|
||||
update: "edr_freight_app:drivers:update",
|
||||
delete: "edr_freight_app:drivers:delete",
|
||||
},
|
||||
trainCrew: {
|
||||
view: "edr_freight_app:train_crew:view",
|
||||
create: "edr_freight_app:train_crew:create",
|
||||
update: "edr_freight_app:train_crew:update",
|
||||
delete: "edr_freight_app:train_crew:delete",
|
||||
},
|
||||
tracking: {
|
||||
view: "edr_freight_app:tracking:view",
|
||||
manage: "edr_freight_app:tracking:manage",
|
||||
|
||||
Reference in New Issue
Block a user