mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
New Transit Agents admin table (name, valid-from/to, active/suspended, validity badge) — DJ assign step now picks from it, active+valid only.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class CreateTransitAgentDto {
|
||||
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-01' })
|
||||
@IsDateString()
|
||||
validFrom!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31' })
|
||||
@IsDateString()
|
||||
validTo!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateTransitAgentDto } from './create-transit-agent.dto';
|
||||
|
||||
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
||||
* transit-assignee handshake. Admin-managed so the roster and each officer's
|
||||
* validity window arrive without a code change; `isActive` is the manual
|
||||
* suspend/reactivate switch, independent of the validity window.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'transit_agents' })
|
||||
@Index(['isActive'])
|
||||
export class TransitAgent extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'valid_from', type: 'date' })
|
||||
validFrom!: string;
|
||||
|
||||
@Column({ name: 'valid_to', type: 'date' })
|
||||
validTo!: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
RuleEngineUpdate,
|
||||
RuleEngineView,
|
||||
} from '../../common/rule-engine-guards';
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
|
||||
@ApiTags('transit-agents')
|
||||
@Controller('transit-agents')
|
||||
@ApiBearerAuth()
|
||||
export class TransitAgentsController {
|
||||
constructor(private readonly transitAgentsService: TransitAgentsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents' })
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.transitAgentsService.findAll({
|
||||
isActive:
|
||||
query.isActive === 'all'
|
||||
? undefined
|
||||
: query.isActive !== undefined
|
||||
? query.isActive === 'true'
|
||||
: undefined,
|
||||
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
|
||||
sortBy: query.sortBy,
|
||||
sortOrder: query.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
||||
@Get('assignable')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
|
||||
findAssignable() {
|
||||
return this.transitAgentsService.findAssignable();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'Get a transit agent by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineCreate('transit-agents')
|
||||
@ApiOperation({ summary: 'Create a transit agent' })
|
||||
create(@Body() dto: CreateTransitAgentDto) {
|
||||
return this.transitAgentsService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineUpdate('transit-agents')
|
||||
@ApiOperation({ summary: 'Update a transit agent' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
|
||||
return this.transitAgentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineDelete('transit-agents')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a transit agent' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsController } from './transit-agents.controller';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TransitAgent])],
|
||||
controllers: [TransitAgentsController],
|
||||
providers: [TransitAgentsRepository, TransitAgentsService],
|
||||
exports: [TransitAgentsRepository, TransitAgentsService],
|
||||
})
|
||||
export class TransitAgentsModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
constructor(
|
||||
@InjectRepository(TransitAgent)
|
||||
repository: Repository<TransitAgent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Active AND currently inside its validity window (today's date, server-side). */
|
||||
findAssignable(today: string): Promise<TransitAgent[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
isActive: true,
|
||||
validFrom: LessThanOrEqual(today),
|
||||
validTo: MoreThanOrEqual(today),
|
||||
},
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsOrder } from 'typeorm';
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
|
||||
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
|
||||
|
||||
export type TransitAgentView = TransitAgent & {
|
||||
validityStatus: TransitAgentValidityStatus;
|
||||
};
|
||||
|
||||
type TransitAgentListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
|
||||
function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
|
||||
const today = todayISODate();
|
||||
if (today < agent.validFrom) return 'NOT_STARTED';
|
||||
if (today > agent.validTo) return 'EXPIRED';
|
||||
return 'VALID';
|
||||
}
|
||||
|
||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
||||
return { ...agent, validityStatus: validityStatus(agent) };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsService {
|
||||
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
|
||||
|
||||
async findAll(filter: TransitAgentListFilter = {}): Promise<{
|
||||
data: TransitAgentView[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
|
||||
? (filter.sortBy as keyof TransitAgent)
|
||||
: 'name';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
const [data, total] = await this.transitAgentsRepository.findAndCount({
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<TransitAgent>,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map(withValidityStatus),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Active and currently inside its validity window — the DJ assignment dropdown. */
|
||||
async findAssignable(): Promise<TransitAgent[]> {
|
||||
return this.transitAgentsRepository.findAssignable(todayISODate());
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TransitAgentView> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
return withValidityStatus(agent);
|
||||
}
|
||||
|
||||
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
|
||||
async getAssignable(id: string): Promise<TransitAgent> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new BadRequestException('Selected transit officer was not found.');
|
||||
}
|
||||
if (!agent.isActive) {
|
||||
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
|
||||
}
|
||||
if (validityStatus(agent) !== 'VALID') {
|
||||
throw new BadRequestException(
|
||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.create({
|
||||
name: dto.name.trim(),
|
||||
validFrom: dto.validFrom,
|
||||
validTo: dto.validTo,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
return withValidityStatus(agent);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
|
||||
const current = await this.findById(id);
|
||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
||||
const nextValidTo = dto.validTo ?? current.validTo;
|
||||
if (nextValidTo < nextValidFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
}
|
||||
|
||||
const updated = await this.transitAgentsRepository.update(id, {
|
||||
...dto,
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
return withValidityStatus(updated);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.transitAgentsRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user