diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 6b0f072e8..b2a1f0c1a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -18,11 +18,11 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { OtpModule } from './modules/otp/otp.module'; -import { ServiceTypesModule } from "./modules/service-types/service-types.module"; -import { CargoTypesModule } from "./modules/cargo-types/cargo-types.module"; +import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; +import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; - +import { DemoUsersSeeder } from "./seed/demo-users.seeder"; @Module({ imports: [ @@ -48,20 +48,22 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder"; FileUploadSettingsModule, DropdownSettingsModule, OtpModule, - ServiceTypesModule, - CargoTypesModule, + RuleEngineModule, BackofficeModule, + DemoPermissionsModule, ], - providers: [EdrOrgSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, - ) {} + private readonly demoUsersSeeder: DemoUsersSeeder, + ) { } async onApplicationBootstrap() { await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.demoUsersSeeder.run(); } } diff --git a/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts b/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts new file mode 100644 index 000000000..786a8c752 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1748514000000-AddRuleEngineTablesAndCodes.ts @@ -0,0 +1,277 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, + TableColumn, +} from 'typeorm'; + +export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterface { + name = 'AddRuleEngineTablesAndCodes1748514000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. Add `code` column to existing tables ─────────────────────────── + + await queryRunner.addColumn( + 'freight.service_types', + new TableColumn({ + name: 'code', + type: 'varchar', + length: '50', + isNullable: true, + }), + ); + await queryRunner.query( + `UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL`, + ); + await queryRunner.changeColumn( + 'freight.service_types', + 'code', + new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }), + ); + await queryRunner.createIndex( + 'freight.service_types', + new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }), + ); + + await queryRunner.addColumn( + 'freight.cargo_types', + new TableColumn({ + name: 'code', + type: 'varchar', + length: '50', + isNullable: true, + }), + ); + await queryRunner.query( + `UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL`, + ); + await queryRunner.changeColumn( + 'freight.cargo_types', + 'code', + new TableColumn({ name: 'code', type: 'varchar', length: '50', isNullable: false }), + ); + await queryRunner.createIndex( + 'freight.cargo_types', + new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }), + ); + + // ── 2. surcharge_types ──────────────────────────────────────────────── + + await queryRunner.createTable( + new Table({ + name: 'surcharge_types', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'code', type: 'varchar', length: '50', isNullable: false }, + { name: 'name', type: 'varchar', length: '100', isNullable: false }, + { name: 'description', type: 'text', isNullable: true }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.surcharge_types', + new TableIndex({ name: 'IDX_surcharge_types_code', columnNames: ['code'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.surcharge_types', + new TableIndex({ name: 'IDX_surcharge_types_is_active', columnNames: ['is_active'] }), + ); + + // ── 3. surcharges ───────────────────────────────────────────────────── + + await queryRunner.createTable( + new Table({ + name: 'surcharges', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'surcharge_type_id', type: 'uuid', isNullable: false }, + { name: 'fee_name', type: 'varchar', length: '255', isNullable: false }, + { name: 'trigger_description', type: 'text', isNullable: true }, + { + name: 'calculation_method', + type: 'enum', + enum: ['PER_TON', 'FLAT_FEE', 'PERCENTAGE'], + default: `'PER_TON'`, + }, + { name: 'rate', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { name: 'currency', type: 'char', length: '3', default: `'USD'` }, + { name: 'apply_to_rail', type: 'boolean', default: false }, + { name: 'apply_to_first_mile', type: 'boolean', default: false }, + { name: 'apply_to_last_mile', type: 'boolean', default: false }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'freight.surcharges', + new TableForeignKey({ + name: 'FK_surcharges_surcharge_type', + columnNames: ['surcharge_type_id'], + referencedTableName: 'freight.surcharge_types', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + await queryRunner.createIndex( + 'freight.surcharges', + new TableIndex({ name: 'IDX_surcharges_surcharge_type_id', columnNames: ['surcharge_type_id'] }), + ); + await queryRunner.createIndex( + 'freight.surcharges', + new TableIndex({ name: 'IDX_surcharges_is_active', columnNames: ['is_active'] }), + ); + + // ── 4. container_types ──────────────────────────────────────────────── + + await queryRunner.createTable( + new Table({ + name: 'container_types', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'size_code', type: 'varchar', length: '20', isNullable: false }, + { name: 'description', type: 'varchar', length: '100', isNullable: true }, + { name: 'containers_per_wagon', type: 'int', isNullable: false }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.container_types', + new TableIndex({ name: 'IDX_container_types_size_code', columnNames: ['size_code'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.container_types', + new TableIndex({ name: 'IDX_container_types_is_active', columnNames: ['is_active'] }), + ); + + // ── 5. weight_limit_rules ───────────────────────────────────────────── + + await queryRunner.createTable( + new Table({ + name: 'weight_limit_rules', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'container_type_id', type: 'uuid', isNullable: false }, + { + name: 'trade_direction', + type: 'enum', + enum: ['IMPORT', 'EXPORT', 'BOTH'], + isNullable: false, + }, + { name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false }, + { + name: 'exceeded_action', + type: 'enum', + enum: ['WARNING_ONLY', 'HARD_BLOCK'], + default: `'WARNING_ONLY'`, + }, + { name: 'surcharge_id', type: 'uuid', isNullable: true }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createForeignKey( + 'freight.weight_limit_rules', + new TableForeignKey({ + name: 'FK_weight_limit_rules_container_type', + columnNames: ['container_type_id'], + referencedTableName: 'freight.container_types', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + await queryRunner.createForeignKey( + 'freight.weight_limit_rules', + new TableForeignKey({ + name: 'FK_weight_limit_rules_surcharge', + columnNames: ['surcharge_id'], + referencedTableName: 'freight.surcharges', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_container_type_id', columnNames: ['container_type_id'] }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_surcharge_id', columnNames: ['surcharge_id'] }), + ); + await queryRunner.createIndex( + 'freight.weight_limit_rules', + new TableIndex({ name: 'IDX_weight_limit_rules_is_active', columnNames: ['is_active'] }), + ); + + // ── 6. priority_rules ───────────────────────────────────────────────── + + await queryRunner.createTable( + new Table({ + name: 'priority_rules', + schema: 'freight', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { + name: 'priority_type', + type: 'enum', + enum: ['USD_PAYER', 'RAIL_AND_FORWARDING', 'GOVERNMENT_ACCOUNT', 'HIGH_VOLUME_SHIPMENT'], + isNullable: false, + }, + { name: 'rule_name', type: 'varchar', length: '255', isNullable: false }, + { name: 'description', type: 'text', isNullable: true }, + { name: 'activation_condition', type: 'text', isNullable: true }, + { name: 'bonus_points', type: 'int', default: 0 }, + { name: 'is_active', type: 'boolean', default: false }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'freight.priority_rules', + new TableIndex({ name: 'IDX_priority_rules_priority_type', columnNames: ['priority_type'], isUnique: true }), + ); + await queryRunner.createIndex( + 'freight.priority_rules', + new TableIndex({ name: 'IDX_priority_rules_is_active', columnNames: ['is_active'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.priority_rules', true); + await queryRunner.dropTable('freight.weight_limit_rules', true); + await queryRunner.dropTable('freight.container_types', true); + await queryRunner.dropTable('freight.surcharges', true); + await queryRunner.dropTable('freight.surcharge_types', true); + await queryRunner.dropIndex('freight.cargo_types', 'IDX_cargo_types_code'); + await queryRunner.dropColumn('freight.cargo_types', 'code'); + await queryRunner.dropIndex('freight.service_types', 'IDX_service_types_code'); + await queryRunner.dropColumn('freight.service_types', 'code'); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 9c1bb0a8c..23d36b222 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -4,13 +4,14 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { CustomersModule } from "../customers/customers.module"; import { FilesModule } from "../files/files.module"; import { MinioModule } from "../minio/minio.module"; +import { RuleEngineModule } from "../rule-engine/rule-engine.module"; import { BookingsController } from "./bookings.controller"; import { BookingsRepository } from "./bookings.repository"; import { BookingsService } from "./bookings.service"; import { Booking } from "./entities/booking.entity"; @Module({ - imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule], + imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule, CustomersModule, RuleEngineModule], controllers: [BookingsController], providers: [BookingsService, BookingsRepository], exports: [BookingsService], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 0287cecc4..1029b4560 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -9,6 +9,7 @@ import { IsNull, Not } from "typeorm"; import { CustomersService } from "../customers/customers.service"; import { FilesService } from "../files/files.service"; import { MinioService } from "../minio/minio.service"; +import { RuleEngineService } from "../rule-engine/rule-engine.service"; import { BookingsRepository } from "./bookings.repository"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; @@ -17,16 +18,6 @@ import { UpdateStatusDto } from "./dto/update-status.dto"; import { Booking } from "./entities/booking.entity"; import { FileRecord } from "../files/entities/file.entity"; -/** Weight thresholds (tons) that trigger overweight surcharge alerts. */ -const WEIGHT_LIMITS = { - IMPORT_20FT: 20, - EXPORT_20FT: 25, - ANY_40FT: 32.5, -} as const; - -/** Bookings above this total VGM are considered high-volume. */ -const HIGH_VOLUME_THRESHOLD_TONS = 500; - @Injectable() export class BookingsService { constructor( @@ -34,6 +25,7 @@ export class BookingsService { private readonly filesService: FilesService, private readonly minioService: MinioService, private readonly customersService: CustomersService, + private readonly ruleEngineService: RuleEngineService, ) {} // ── helpers ────────────────────────────────────────────────────────── @@ -65,15 +57,6 @@ export class BookingsService { return explicit ?? false; } - /** Calculate priority score based on currency and service type. */ - private calculatePriorityScore(currency: string, serviceType: string): number { - let score = 0; - if (currency === "USD") score += 100; - if (serviceType === "RAIL_AND_FORWARDING") score += 50; - else if (serviceType === "RAIL_ONLY") score += 25; - return score; - } - /** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */ private calculateWagonCount( containers: Array<{ type: string; qty: number }>, @@ -87,33 +70,6 @@ export class BookingsService { }, 0); } - /** Check per-container weight limits and return warnings if exceeded. */ - private checkOverweight( - containers: Array<{ type: string; vgm: number }>, - tradeDirection: string, - ): string[] { - const warnings: string[] = []; - for (const container of containers) { - if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) { - warnings.push( - `40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t` - ); - } - if (container.type === "20FT") { - const limit = - tradeDirection === "IMPORT" - ? WEIGHT_LIMITS.IMPORT_20FT - : WEIGHT_LIMITS.EXPORT_20FT; - if (container.vgm > limit) { - warnings.push( - `20FT ${tradeDirection} container VGM ${container.vgm}t exceeds limit of ${limit}t` - ); - } - } - } - return warnings; - } - // ── CRUD ───────────────────────────────────────────────────────────── @@ -143,16 +99,19 @@ export class BookingsService { dto.allowConsolidation, ); - const priorityScore = this.calculatePriorityScore( - dto.paymentCurrency, - dto.serviceType, - ); - - const overweightWarnings = this.checkOverweight( - dto.containers, - dto.tradeDirection, - ); - warnings.push(...overweightWarnings); + // ── Rule engine evaluation ────────────────────────────────────────── + const ruleResult = await this.ruleEngineService.evaluate({ + freightType: dto.freightType, + serviceType: dto.serviceType, + paymentCurrency: dto.paymentCurrency, + cargoTotalWeightVgm: dto.cargoTotalWeightVgm, + tradeDirection: dto.tradeDirection, + isHazardous: dto.isHazardous ?? false, + isRefrigerated: dto.isRefrigerated ?? false, + containers: dto.containers, + }); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + warnings.push(...ruleResult.warnings); const wagonCount = this.calculateWagonCount(dto.containers); warnings.push(`Estimated wagons required: ${wagonCount}`); @@ -168,7 +127,7 @@ export class BookingsService { endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: "DRAFT", allowConsolidation, - priorityScore, + priorityScore: ruleResult.priorityScore, }); if (files.length > 0) { @@ -208,15 +167,20 @@ export class BookingsService { dto.allowConsolidation, ); - // Recalculate priority - const currency = dto.paymentCurrency ?? existing.paymentCurrency; - const serviceType = dto.serviceType ?? existing.serviceType; - updates.priorityScore = this.calculatePriorityScore(currency, serviceType); - - // Overweight check - const direction = dto.tradeDirection ?? existing.tradeDirection; - const overweightWarnings = this.checkOverweight(containers, direction); - warnings.push(...overweightWarnings); + // ── Rule engine re-evaluation ──────────────────────────────────────── + const ruleResult = await this.ruleEngineService.evaluate({ + freightType: dto.freightType ?? existing.freightType, + serviceType: dto.serviceType ?? existing.serviceType, + paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, + cargoTotalWeightVgm: dto.cargoTotalWeightVgm ?? existing.cargoTotalWeightVgm, + tradeDirection: dto.tradeDirection ?? existing.tradeDirection, + isHazardous: dto.isHazardous ?? existing.isHazardous ?? false, + isRefrigerated: dto.isRefrigerated ?? existing.isRefrigerated ?? false, + containers, + }); + this.ruleEngineService.assertNoHardBlocks(ruleResult); + warnings.push(...ruleResult.warnings); + updates.priorityScore = ruleResult.priorityScore; if (files.length > 0) { await this.filesService.uploadMany(id, "bookings", files); @@ -348,13 +312,13 @@ export class BookingsService { } } - /** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (bulk). */ + /** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (cargo routing from rule engine). */ private async handleSubmit(booking: Booking): Promise { this.assertStatus(booking, ["DRAFT"]); - const isBulk = - booking.freightType === "BULK" || - booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS; - const nextStatus = isBulk ? "PENDING_DIRECTOR" : "PENDING_LINE_STAFF"; + const ruleResult = await this.ruleEngineService.evaluate(booking); + const nextStatus = ruleResult.requiresDirectorApproval + ? "PENDING_DIRECTOR" + : "PENDING_LINE_STAFF"; const updated = await this.bookingsRepository.update(booking.id, { status: nextStatus, } as never); @@ -370,13 +334,11 @@ export class BookingsService { if (!actorId) throw new BadRequestException("actorId is required for APPROVE_STAFF"); - // Line staff cannot approve bulk - if ( - booking.freightType === "BULK" || - booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS - ) { + // Line staff cannot approve bookings that require director approval + const ruleResult = await this.ruleEngineService.evaluate(booking); + if (ruleResult.requiresDirectorApproval) { throw new BadRequestException( - "Line staff cannot approve bulk or high-volume bookings", + "Line staff cannot approve bookings that require director approval", ); } @@ -400,10 +362,8 @@ export class BookingsService { if (!actorId) throw new BadRequestException("actorId is required for APPROVE_DIRECTOR"); - const isBulk = - booking.freightType === "BULK" || - booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS; - const nextStatus = isBulk ? "PENDING_CEO" : "SIGNED"; + const ruleResult = await this.ruleEngineService.evaluate(booking); + const nextStatus = ruleResult.requiresDirectorApproval ? "PENDING_CEO" : "SIGNED"; const updated = await this.bookingsRepository.update(booking.id, { status: nextStatus, diff --git a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.controller.ts deleted file mode 100644 index 03b9e6594..000000000 --- a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Patch, - Post, - Query, -} from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; - -import { CargoTypesService } from "./cargo-types.service"; -import { CreateCargoTypeDto } from "./dto/create-cargo-type.dto"; -import { FilterCargoTypeDto } from "./dto/filter-cargo-type.dto"; -import { UpdateCargoTypeDto } from "./dto/update-cargo-type.dto"; - -@ApiTags("cargo-types") -@Controller("cargo-types") -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated -@ApiBearerAuth() -export class CargoTypesController { - constructor(private readonly service: CargoTypesService) {} - - @Get() - @ApiOperation({ - summary: "List cargo types", - description: "Paginated list with optional filtering by isActive, requiresDirectorApproval, parentGroupId, and name search.", - }) - findAll(@Query() filter: FilterCargoTypeDto) { - return this.service.findAll(filter); - } - - @Get(":id") - @ApiOperation({ summary: "Get a cargo type by ID", description: "Returns the cargo type with parent and children relations." }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.service.findById(id); - } - - @Post() - @ApiOperation({ summary: "Create a new cargo type" }) - create(@Body() dto: CreateCargoTypeDto) { - return this.service.create(dto); - } - - @Patch(":id") - @ApiOperation({ summary: "Update a cargo type" }) - update( - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateCargoTypeDto, - ) { - return this.service.update(id, dto); - } - - @Delete(":id") - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: "Soft-delete a cargo type" }) - remove(@Param("id", ParseUUIDPipe) id: string) { - return this.service.remove(id); - } -} diff --git a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.module.ts b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.module.ts deleted file mode 100644 index a79759dfd..000000000 --- a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { CargoType } from "./entities/cargo-type.entity"; -import { CARGO_TYPES_REPOSITORY } from "./interfaces/cargo-types.repository.interface"; -import { CargoTypesRepository } from "./cargo-types.repository"; -import { CargoTypesController } from "./cargo-types.controller"; -import { CargoTypesService } from "./cargo-types.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([CargoType])], - controllers: [CargoTypesController], - providers: [ - CargoTypesRepository, - { - provide: CARGO_TYPES_REPOSITORY, - useExisting: CargoTypesRepository, - }, - CargoTypesService, - ], - exports: [CargoTypesService], -}) -export class CargoTypesModule {} diff --git a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.repository.ts deleted file mode 100644 index 4f71c61a1..000000000 --- a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.repository.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { BaseRepository } from "@edr/api-common"; -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { FindManyOptions, Repository } from "typeorm"; - -import { CargoType } from "./entities/cargo-type.entity"; -import { ICargoTypesRepository } from "./interfaces/cargo-types.repository.interface"; - -@Injectable() -export class CargoTypesRepository - extends BaseRepository - implements ICargoTypesRepository -{ - constructor( - @InjectRepository(CargoType) - repository: Repository, - ) { - super(repository); - } - - override findById(id: string): Promise { - return this.repository.findOne({ - where: { id }, - relations: { parent: true, children: true }, - }); - } - - override findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]> { - return this.repository.findAndCount(options); - } -} diff --git a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.service.ts b/apps/edr-freight-api/src/modules/cargo-types/cargo-types.service.ts deleted file mode 100644 index 2ad4339c5..000000000 --- a/apps/edr-freight-api/src/modules/cargo-types/cargo-types.service.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { ConflictException, Inject, Injectable, NotFoundException } from "@nestjs/common"; -import { ILike } from "typeorm"; - -import { CreateCargoTypeDto } from "./dto/create-cargo-type.dto"; -import { FilterCargoTypeDto } from "./dto/filter-cargo-type.dto"; -import { UpdateCargoTypeDto } from "./dto/update-cargo-type.dto"; -import { CargoType } from "./entities/cargo-type.entity"; -import { - CARGO_TYPES_REPOSITORY, - ICargoTypesRepository, -} from "./interfaces/cargo-types.repository.interface"; - -@Injectable() -export class CargoTypesService { - constructor( - @Inject(CARGO_TYPES_REPOSITORY) - private readonly repository: ICargoTypesRepository, - ) {} - - /** - * Find all cargo types with pagination and optional filtering. - */ - async findAll(filter: FilterCargoTypeDto): Promise<{ - data: CargoType[]; - meta: { - total: number; - page: number; - pageSize: number; - totalPages: number; - }; - }> { - const where: Record = {}; - - if (filter.isActive !== undefined) { - where.isActive = filter.isActive; - } - - if (filter.requiresDirectorApproval !== undefined) { - where.requiresDirectorApproval = filter.requiresDirectorApproval; - } - - if (filter.parentGroupId !== undefined) { - where.parentGroupId = filter.parentGroupId; - } - - if (filter.search) { - where.cargoTypeName = ILike(`%${filter.search}%`); - } - - const [data, total] = await this.repository.findAndCount({ - where, - order: { [filter.sortBy!]: filter.sortOrder }, - skip: (filter.page! - 1) * filter.pageSize!, - take: filter.pageSize, - relations: { parent: true }, - }); - - return { - data, - meta: { - total, - page: filter.page!, - pageSize: filter.pageSize!, - totalPages: Math.ceil(total / filter.pageSize!), - }, - }; - } - - /** - * Get a single cargo type by ID. - */ - async findById(id: string): Promise { - const entity = await this.repository.findById(id); - if (!entity) { - throw new NotFoundException(`Cargo type ${id} not found`); - } - return entity; - } - - /** - * Create a new cargo type. - */ - async create(dto: CreateCargoTypeDto): Promise { - // Validate parent exists if provided - if (dto.parentGroupId) { - const parent = await this.repository.findById(dto.parentGroupId); - if (!parent) { - throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); - } - } - - return this.repository.create({ - cargoTypeName: dto.cargoTypeName, - parentGroupId: dto.parentGroupId ?? null, - showFreeTextBox: dto.showFreeTextBox ?? false, - requiresDirectorApproval: dto.requiresDirectorApproval ?? false, - isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, - }); - } - - /** - * Update an existing cargo type. - */ - async update(id: string, dto: UpdateCargoTypeDto): Promise { - await this.findById(id); - - // Validate parent exists if provided - const parentGroupId = dto.parentGroupId; - if (parentGroupId) { - const parent = await this.repository.findById(parentGroupId); - if (!parent) { - throw new NotFoundException(`Parent cargo type ${parentGroupId} not found`); - } - // Prevent circular reference - if (parentGroupId === id) { - throw new ConflictException("A cargo type cannot be its own parent"); - } - } - - const updated = await this.repository.update(id, dto); - if (!updated) { - throw new NotFoundException(`Cargo type ${id} not found`); - } - return this.findById(id); - } - - /** - * Soft-delete a cargo type. - */ - async remove(id: string): Promise { - await this.findById(id); - await this.repository.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/modules/cargo-types/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/cargo-types/dto/create-cargo-type.dto.ts deleted file mode 100644 index add968ed5..000000000 --- a/apps/edr-freight-api/src/modules/cargo-types/dto/create-cargo-type.dto.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from "class-validator"; - -export class CreateCargoTypeDto { - @ApiProperty({ description: "Cargo type name", maxLength: 255 }) - @IsString() - @MaxLength(255) - cargoTypeName!: string; - - @ApiPropertyOptional({ description: "Parent cargo type group ID (UUID) for hierarchical structure" }) - @IsOptional() - @IsUUID() - parentGroupId?: string; - - @ApiPropertyOptional({ description: "Show free text box for this cargo type", default: false }) - @IsOptional() - @IsBoolean() - showFreeTextBox?: boolean = false; - - @ApiPropertyOptional({ description: "Requires director approval for this cargo type", default: false }) - @IsOptional() - @IsBoolean() - requiresDirectorApproval?: boolean = false; - - @ApiPropertyOptional({ description: "Is the cargo type active", default: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean = true; - - @ApiPropertyOptional({ description: "Display order for UI", default: 1 }) - @IsOptional() - @IsInt() - @Min(1) - displayOrder?: number = 1; -} diff --git a/apps/edr-freight-api/src/modules/cargo-types/dto/filter-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/cargo-types/dto/filter-cargo-type.dto.ts deleted file mode 100644 index 69a6141a9..000000000 --- a/apps/edr-freight-api/src/modules/cargo-types/dto/filter-cargo-type.dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform, Type } from "class-transformer"; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator"; - -export class FilterCargoTypeDto { - @ApiPropertyOptional({ description: "Filter by active status" }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) - isActive?: boolean; - - @ApiPropertyOptional({ description: "Filter by requires director approval" }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) - requiresDirectorApproval?: boolean; - - @ApiPropertyOptional({ description: "Filter by parent group ID (or 'root' for top-level only)" }) - @IsOptional() - @IsUUID() - parentGroupId?: string; - - @ApiPropertyOptional({ description: "Search by cargo type name (case-insensitive)" }) - @IsOptional() - @IsString() - search?: string; - - @ApiPropertyOptional({ enum: ["cargoTypeName", "displayOrder", "createdAt"], default: "displayOrder" }) - @IsOptional() - @IsIn(["cargoTypeName", "displayOrder", "createdAt"]) - sortBy?: string = "displayOrder"; - - @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) - @IsOptional() - @IsIn(["ASC", "DESC"]) - sortOrder?: "ASC" | "DESC" = "ASC"; - - @ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page?: number = 1; - - @ApiPropertyOptional({ description: "Items per page", default: 20, minimum: 1 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - pageSize?: number = 20; -} diff --git a/apps/edr-freight-api/src/modules/cargo-types/dto/update-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/cargo-types/dto/update-cargo-type.dto.ts deleted file mode 100644 index 638422fd7..000000000 --- a/apps/edr-freight-api/src/modules/cargo-types/dto/update-cargo-type.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PartialType } from "@nestjs/mapped-types"; - -import { CreateCargoTypeDto } from "./create-cargo-type.dto"; - -export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts deleted file mode 100644 index 14589ae4b..000000000 --- a/apps/edr-freight-api/src/modules/cargo-types/entities/cargo-type.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BaseEntity } from "@edr/api-common"; -import { Column, Entity, Index, ManyToOne, OneToMany, JoinColumn } from "typeorm"; - -@Entity({ schema: "freight", name: "cargo_types" }) -@Index(["isActive"]) -@Index(["displayOrder"]) -@Index(["parentGroupId"]) -export class CargoType extends BaseEntity { - @Column({ name: "cargo_type_name", type: "varchar", length: 255, nullable: false }) - cargoTypeName!: string; - - @Column({ name: "parent_group_id", type: "uuid", nullable: true }) - parentGroupId?: string | null; - - @Column({ name: "show_free_text_box", type: "boolean", default: false }) - showFreeTextBox!: boolean; - - @Column({ name: "requires_director_approval", type: "boolean", default: false }) - requiresDirectorApproval!: boolean; - - @Column({ name: "is_active", type: "boolean", default: true }) - isActive!: boolean; - - @Column({ name: "display_order", type: "int", default: 1 }) - displayOrder!: number; - - @ManyToOne(() => CargoType, (cargoType) => cargoType.children, { - nullable: true, - onDelete: "SET NULL", - }) - @JoinColumn({ name: "parent_group_id" }) - parent?: CargoType | null; - - @OneToMany(() => CargoType, (cargoType) => cargoType.parent) - children?: CargoType[]; -} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts new file mode 100644 index 000000000..6f425e1bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PermissionGuard } from "@tria-plc/api-common/modules/auth/services/permission.guard"; + +@ApiTags("demo-permissions") +@Controller() +export class DemoPermissionsController { + @Get("test_user1") + @ApiOperation({ summary: "Permission demo (can:demo:user1)" }) + @UseGuards(PermissionGuard(["can:demo:user1"])) + testUser1() { + return { ok: true, permission: "can:demo:user1" }; + } + + @Get("test_user2") + @ApiOperation({ summary: "Permission demo (can:demo:user2)" }) + @UseGuards(PermissionGuard(["can:demo:user2"])) + testUser2() { + return { ok: true, permission: "can:demo:user2" }; + } +} diff --git a/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts new file mode 100644 index 000000000..db73ed728 --- /dev/null +++ b/apps/edr-freight-api/src/modules/demo-permissions/demo-permissions.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; + +import { DemoPermissionsController } from "./demo-permissions.controller"; + +@Module({ + controllers: [DemoPermissionsController], +}) +export class DemoPermissionsModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts new file mode 100644 index 000000000..de855c7ef --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -0,0 +1,58 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; +import { CargoTypesService } from '../services/cargo-types.service'; + +@ApiTags('cargo-types') +@Controller('cargo-types') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class CargoTypesController { + constructor(private readonly service: CargoTypesService) {} + + @Get() + @ApiOperation({ summary: 'List cargo types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined + ? query['requiresDirectorApproval'] === 'true' + : undefined, + parentGroupId: query['parentGroupId'], + search: query['search'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + sortBy: query['sortBy'], + sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC', + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a cargo type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a cargo type' }) + create(@Body() dto: CreateCargoTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a cargo type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a cargo type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts new file mode 100644 index 000000000..380688309 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -0,0 +1,51 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; +import { ContainerTypesService } from '../services/container-types.service'; + +@ApiTags('container-types') +@Controller('container-types') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class ContainerTypesController { + constructor(private readonly service: ContainerTypesService) {} + + @Get() + @ApiOperation({ summary: 'List container types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a container type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a container type' }) + create(@Body() dto: CreateContainerTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a container type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a container type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts new file mode 100644 index 000000000..d268fc577 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts @@ -0,0 +1,51 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto'; +import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto'; +import { PriorityRulesService } from '../services/priority-rules.service'; + +@ApiTags('priority-rules') +@Controller('priority-rules') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class PriorityRulesController { + constructor(private readonly service: PriorityRulesService) {} + + @Get() + @ApiOperation({ summary: 'List priority rules' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a priority rule by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a priority rule' }) + create(@Body() dto: CreatePriorityRuleDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a priority rule' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a priority rule' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts new file mode 100644 index 000000000..263178a92 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -0,0 +1,55 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; +import { ServiceTypesService } from '../services/service-types.service'; + +@ApiTags('service-types') +@Controller('service-types') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class ServiceTypesController { + constructor(private readonly service: ServiceTypesService) {} + + @Get() + @ApiOperation({ summary: 'List service types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined, + search: query['search'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + sortBy: query['sortBy'], + sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC', + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a service type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a service type' }) + create(@Body() dto: CreateServiceTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a service type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a service type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts new file mode 100644 index 000000000..24213a28f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts @@ -0,0 +1,51 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; +import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; +import { SurchargeTypesService } from '../services/surcharge-types.service'; + +@ApiTags('surcharge-types') +@Controller('surcharge-types') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class SurchargeTypesController { + constructor(private readonly service: SurchargeTypesService) {} + + @Get() + @ApiOperation({ summary: 'List surcharge types' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a surcharge type by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a surcharge type' }) + create(@Body() dto: CreateSurchargeTypeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a surcharge type' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a surcharge type' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharges.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharges.controller.ts new file mode 100644 index 000000000..1da10c560 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharges.controller.ts @@ -0,0 +1,52 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateSurchargeDto } from '../dto/create-surcharge.dto'; +import { UpdateSurchargeDto } from '../dto/update-surcharge.dto'; +import { SurchargesService } from '../services/surcharges.service'; + +@ApiTags('surcharges') +@Controller('surcharges') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class SurchargesController { + constructor(private readonly service: SurchargesService) {} + + @Get() + @ApiOperation({ summary: 'List surcharges' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + surchargeTypeId: query['surchargeTypeId'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a surcharge by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a surcharge' }) + create(@Body() dto: CreateSurchargeDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a surcharge' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a surcharge' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts new file mode 100644 index 000000000..9a0570ff7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -0,0 +1,52 @@ +import { + Body, Controller, Delete, Get, HttpCode, HttpStatus, + Param, ParseUUIDPipe, Patch, Post, Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; +import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; +import { WeightLimitRulesService } from '../services/weight-limit-rules.service'; + +@ApiTags('weight-limit-rules') +@Controller('weight-limit-rules') +// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated +@ApiBearerAuth() +export class WeightLimitRulesController { + constructor(private readonly service: WeightLimitRulesService) {} + + @Get() + @ApiOperation({ summary: 'List weight limit rules' }) + findAll(@Query() query: Record) { + return this.service.findAll({ + isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, + containerTypeId: query['containerTypeId'], + page: query['page'] ? parseInt(query['page'], 10) : undefined, + pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a weight limit rule by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a weight limit rule' }) + create(@Body() dto: CreateWeightLimitRuleDto) { + return this.service.create(dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a weight limit rule' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a weight limit rule' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts new file mode 100644 index 000000000..53a007b80 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -0,0 +1,40 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; + +export class CreateCargoTypeDto { + @ApiProperty({ description: 'Machine-readable code, e.g. BULK, BREAK_BULK', maxLength: 50 }) + @IsString() + @MaxLength(50) + code!: string; + + @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) + @IsString() + @MaxLength(255) + cargoTypeName!: string; + + @ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' }) + @IsOptional() + @IsUUID() + parentGroupId?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + showFreeTextBox?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + requiresDirectorApproval?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts new file mode 100644 index 000000000..76d82a450 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CreateContainerTypeDto { + @ApiProperty({ description: 'Size code, e.g. 20FT or 40FT', maxLength: 20 }) + @IsString() + @MaxLength(20) + sizeCode!: string; + + @ApiPropertyOptional({ description: 'Human-readable description', maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + description?: string; + + @ApiProperty({ description: 'Number of containers that fit per rail wagon (2 for 20FT, 1 for 40FT)' }) + @IsInt() + @Min(1) + containersPerWagon!: number; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-rule.dto.ts new file mode 100644 index 000000000..2b55d388c --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-priority-rule.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { Freight } from '@edr/types'; + +export class CreatePriorityRuleDto { + @ApiProperty({ enum: Freight.PriorityType, description: 'Priority type (unique per rule)' }) + @IsEnum(Freight.PriorityType) + priorityType!: Freight.PriorityType; + + @ApiProperty({ description: 'Human-readable rule name', maxLength: 255 }) + @IsString() + @MaxLength(255) + ruleName!: string; + + @ApiPropertyOptional({ description: 'Explanation of when this rule is triggered' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Technical expression describing the activation condition' }) + @IsOptional() + @IsString() + activationCondition?: string; + + @ApiProperty({ description: 'Points added to booking.priorityScore when this rule matches', default: 0 }) + @IsInt() + @Min(0) + bonusPoints!: number; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts new file mode 100644 index 000000000..6fe8a3227 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-service-type.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; + +export class CreateServiceTypeDto { + @ApiProperty({ description: 'Machine-readable code, e.g. RAIL_ONLY', maxLength: 50 }) + @IsString() + @MaxLength(50) + code!: string; + + @ApiProperty({ description: 'Service type display name', maxLength: 255 }) + @IsString() + @MaxLength(255) + serviceName!: string; + + @ApiPropertyOptional({ description: 'Detailed description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + canBeBookedAlone?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesFirstMile?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesLastMile?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + includesCustoms?: boolean; + + @ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + priorityBonusPoints?: number; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + displayOrder?: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts new file mode 100644 index 000000000..4c27e368f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CreateSurchargeTypeDto { + @ApiProperty({ description: 'Unique code, e.g. HAZARDOUS, REFRIGERATED', maxLength: 50 }) + @IsString() + @MaxLength(50) + code!: string; + + @ApiProperty({ description: 'Display name', maxLength: 100 }) + @IsString() + @MaxLength(100) + name!: string; + + @ApiPropertyOptional({ description: 'Description of when this surcharge type is triggered' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge.dto.ts new file mode 100644 index 000000000..1122ba39f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUUID, + Length, + MaxLength, + Min, +} from 'class-validator'; +import { Freight } from '@edr/types'; + +export class CreateSurchargeDto { + @ApiProperty({ description: 'FK to surcharge_types.id' }) + @IsUUID() + surchargeTypeId!: string; + + @ApiProperty({ description: 'Display name for this surcharge line item', maxLength: 255 }) + @IsString() + @MaxLength(255) + feeName!: string; + + @ApiPropertyOptional({ description: 'Human-readable description of when this surcharge is triggered' }) + @IsOptional() + @IsString() + triggerDescription?: string; + + @ApiProperty({ enum: Freight.CalculationMethod, default: Freight.CalculationMethod.PER_TON }) + @IsEnum(Freight.CalculationMethod) + calculationMethod!: Freight.CalculationMethod; + + @ApiProperty({ description: 'Rate amount (per ton, flat, or percentage)' }) + @IsNumber() + @Min(0) + rate!: number; + + @ApiProperty({ description: 'ISO 4217 currency code', default: 'USD' }) + @IsString() + @Length(3, 3) + currency!: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + applyToRail?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + applyToFirstMile?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + applyToLastMile?: boolean; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts new file mode 100644 index 000000000..eb5bfc14c --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { Freight } from '@edr/types'; + +export class CreateWeightLimitRuleDto { + @ApiProperty({ description: 'FK to container_types.id' }) + @IsUUID() + containerTypeId!: string; + + @ApiProperty({ enum: Freight.TradeDirection, description: 'Trade direction this rule applies to' }) + @IsEnum(Freight.TradeDirection) + tradeDirection!: Freight.TradeDirection; + + @ApiProperty({ description: 'Maximum allowed weight in tons before surcharge is applied' }) + @IsNumber() + @Min(0) + maxWeightTons!: number; + + @ApiProperty({ description: 'Weight at which a warning is issued (must be ≤ maxWeightTons)' }) + @IsNumber() + @Min(0) + warningThresholdTons!: number; + + @ApiPropertyOptional({ enum: Freight.ExceededAction, default: Freight.ExceededAction.WARNING_ONLY }) + @IsOptional() + @IsEnum(Freight.ExceededAction) + exceededAction?: Freight.ExceededAction; + + @ApiPropertyOptional({ description: 'FK to surcharges.id — surcharge billed when max is exceeded' }) + @IsOptional() + @IsUUID() + surchargeId?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-cargo-type.dto.ts new file mode 100644 index 000000000..fd7e82cff --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-cargo-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCargoTypeDto } from './create-cargo-type.dto'; + +export class UpdateCargoTypeDto extends PartialType(CreateCargoTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts new file mode 100644 index 000000000..6fd94ceb8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-container-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateContainerTypeDto } from './create-container-type.dto'; + +export class UpdateContainerTypeDto extends PartialType(CreateContainerTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-rule.dto.ts new file mode 100644 index 000000000..f1e5c9be3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-priority-rule.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreatePriorityRuleDto } from './create-priority-rule.dto'; + +export class UpdatePriorityRuleDto extends PartialType(CreatePriorityRuleDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-service-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-service-type.dto.ts new file mode 100644 index 000000000..8f85a656a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-service-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateServiceTypeDto } from './create-service-type.dto'; + +export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts new file mode 100644 index 000000000..cb9be80eb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateSurchargeTypeDto } from './create-surcharge-type.dto'; + +export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge.dto.ts new file mode 100644 index 000000000..c87a7b5a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateSurchargeDto } from './create-surcharge.dto'; + +export class UpdateSurchargeDto extends PartialType(CreateSurchargeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts new file mode 100644 index 000000000..4841e9e42 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-weight-limit-rule.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateWeightLimitRuleDto } from './create-weight-limit-rule.dto'; + +export class UpdateWeightLimitRuleDto extends PartialType(CreateWeightLimitRuleDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts new file mode 100644 index 000000000..a0bd9ddaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'cargo_types' }) +@Index(['isActive']) +@Index(['displayOrder']) +@Index(['parentGroupId']) +@Index(['code']) +export class CargoType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) + code!: string; + + @Column({ name: 'cargo_type_name', type: 'varchar', length: 255 }) + cargoTypeName!: string; + + @Column({ name: 'parent_group_id', type: 'uuid', nullable: true }) + parentGroupId?: string | null; + + @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) + showFreeTextBox!: boolean; + + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) + requiresDirectorApproval!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1 }) + displayOrder!: number; + + @ManyToOne(() => CargoType, (ct) => ct.children, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_group_id' }) + parent?: CargoType | null; + + @OneToMany(() => CargoType, (ct) => ct.parent) + children?: CargoType[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts new file mode 100644 index 000000000..5bebb8e65 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -0,0 +1,23 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { WeightLimitRule } from './weight-limit-rule.entity'; + +@Entity({ schema: 'freight', name: 'container_types' }) +@Index(['sizeCode']) +@Index(['isActive']) +export class ContainerType extends BaseEntity { + @Column({ name: 'size_code', type: 'varchar', length: 20, unique: true }) + sizeCode!: string; + + @Column({ name: 'description', type: 'varchar', length: 100, nullable: true }) + description?: string | null; + + @Column({ name: 'containers_per_wagon', type: 'int' }) + containersPerWagon!: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => WeightLimitRule, (rule) => rule.containerType) + weightLimitRules?: WeightLimitRule[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule.entity.ts new file mode 100644 index 000000000..c9159c2ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'priority_rules' }) +@Index(['priorityType']) +@Index(['isActive']) +export class PriorityRule extends BaseEntity { + @Column({ name: 'priority_type', type: 'enum', enum: Freight.PriorityType, unique: true }) + priorityType!: Freight.PriorityType; + + @Column({ name: 'rule_name', type: 'varchar', length: 255 }) + ruleName!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'activation_condition', type: 'text', nullable: true }) + activationCondition?: string | null; + + @Column({ name: 'bonus_points', type: 'int', default: 0 }) + bonusPoints!: number; + + @Column({ name: 'is_active', type: 'boolean', default: false }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts new file mode 100644 index 000000000..2b7cb3f23 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/service-type.entity.ts @@ -0,0 +1,38 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'service_types' }) +@Index(['isActive']) +@Index(['displayOrder']) +@Index(['code']) +export class ServiceType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) + code!: string; + + @Column({ name: 'service_name', type: 'varchar', length: 255 }) + serviceName!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'can_be_booked_alone', type: 'boolean', default: true }) + canBeBookedAlone!: boolean; + + @Column({ name: 'includes_first_mile', type: 'boolean', default: false }) + includesFirstMile!: boolean; + + @Column({ name: 'includes_last_mile', type: 'boolean', default: false }) + includesLastMile!: boolean; + + @Column({ name: 'includes_customs', type: 'boolean', default: false }) + includesCustoms!: boolean; + + @Column({ name: 'priority_bonus_points', type: 'int', default: 0 }) + priorityBonusPoints!: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'display_order', type: 'int', default: 1 }) + displayOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts new file mode 100644 index 000000000..b8c7679a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts @@ -0,0 +1,23 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { Surcharge } from './surcharge.entity'; + +@Entity({ schema: 'freight', name: 'surcharge_types' }) +@Index(['code']) +@Index(['isActive']) +export class SurchargeType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 50, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => Surcharge, (s) => s.surchargeType) + surcharges?: Surcharge[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge.entity.ts new file mode 100644 index 000000000..e8f33f42d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge.entity.ts @@ -0,0 +1,52 @@ +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { SurchargeType } from './surcharge-type.entity'; +import { WeightLimitRule } from './weight-limit-rule.entity'; + +@Entity({ schema: 'freight', name: 'surcharges' }) +@Index(['surchargeTypeId']) +@Index(['isActive']) +export class Surcharge extends BaseEntity { + @Column({ name: 'surcharge_type_id', type: 'uuid' }) + surchargeTypeId!: string; + + @ManyToOne(() => SurchargeType, (st) => st.surcharges) + @JoinColumn({ name: 'surcharge_type_id' }) + surchargeType!: SurchargeType; + + @Column({ name: 'fee_name', type: 'varchar', length: 255 }) + feeName!: string; + + @Column({ name: 'trigger_description', type: 'text', nullable: true }) + triggerDescription?: string | null; + + @Column({ + name: 'calculation_method', + type: 'enum', + enum: Freight.CalculationMethod, + default: Freight.CalculationMethod.PER_TON, + }) + calculationMethod!: Freight.CalculationMethod; + + @Column({ name: 'rate', type: 'numeric', precision: 10, scale: 2 }) + rate!: number; + + @Column({ name: 'currency', type: 'char', length: 3, default: 'USD' }) + currency!: string; + + @Column({ name: 'apply_to_rail', type: 'boolean', default: false }) + applyToRail!: boolean; + + @Column({ name: 'apply_to_first_mile', type: 'boolean', default: false }) + applyToFirstMile!: boolean; + + @Column({ name: 'apply_to_last_mile', type: 'boolean', default: false }) + applyToLastMile!: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => WeightLimitRule, (rule) => rule.surcharge) + weightLimitRules?: WeightLimitRule[]; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts new file mode 100644 index 000000000..cc8cfeb12 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -0,0 +1,45 @@ +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from './container-type.entity'; +import { Surcharge } from './surcharge.entity'; + +@Entity({ schema: 'freight', name: 'weight_limit_rules' }) +@Index(['containerTypeId']) +@Index(['surchargeId']) +@Index(['isActive']) +export class WeightLimitRule extends BaseEntity { + @Column({ name: 'container_type_id', type: 'uuid' }) + containerTypeId!: string; + + @ManyToOne(() => ContainerType, (ct) => ct.weightLimitRules) + @JoinColumn({ name: 'container_type_id' }) + containerType!: ContainerType; + + @Column({ name: 'trade_direction', type: 'enum', enum: Freight.TradeDirection }) + tradeDirection!: Freight.TradeDirection; + + @Column({ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2 }) + maxWeightTons!: number; + + @Column({ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2 }) + warningThresholdTons!: number; + + @Column({ + name: 'exceeded_action', + type: 'enum', + enum: Freight.ExceededAction, + default: Freight.ExceededAction.WARNING_ONLY, + }) + exceededAction!: Freight.ExceededAction; + + @Column({ name: 'surcharge_id', type: 'uuid', nullable: true }) + surchargeId?: string | null; + + @ManyToOne(() => Surcharge, (s) => s.weightLimitRules, { nullable: true }) + @JoinColumn({ name: 'surcharge_id' }) + surcharge?: Surcharge | null; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/cargo-types/interfaces/cargo-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts similarity index 64% rename from apps/edr-freight-api/src/modules/cargo-types/interfaces/cargo-types.repository.interface.ts rename to apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts index 21f501511..d757b6569 100644 --- a/apps/edr-freight-api/src/modules/cargo-types/interfaces/cargo-types.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts @@ -1,9 +1,9 @@ -import { FindManyOptions } from "typeorm"; - -import { CargoType } from "../entities/cargo-type.entity"; +import { FindManyOptions } from 'typeorm'; +import { CargoType } from '../entities/cargo-type.entity'; export interface ICargoTypesRepository { findById(id: string): Promise; + findByCode(code: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]>; create(data: Partial): Promise; @@ -11,4 +11,4 @@ export interface ICargoTypesRepository { softDelete(id: string): Promise; } -export const CARGO_TYPES_REPOSITORY = Symbol("CARGO_TYPES_REPOSITORY"); +export const CARGO_TYPES_REPOSITORY = Symbol('CARGO_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts new file mode 100644 index 000000000..cc1d3f010 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { ContainerType } from '../entities/container-type.entity'; + +export interface IContainerTypesRepository { + findById(id: string): Promise; + findBySizeCode(sizeCode: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[ContainerType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const CONTAINER_TYPES_REPOSITORY = Symbol('CONTAINER_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-rules.repository.interface.ts new file mode 100644 index 000000000..608d06e4c --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-rules.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { PriorityRule } from '../entities/priority-rule.entity'; + +export interface IPriorityRulesRepository { + findById(id: string): Promise; + findAllActive(): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[PriorityRule[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const PRIORITY_RULES_REPOSITORY = Symbol('PRIORITY_RULES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/service-types/interfaces/service-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts similarity index 65% rename from apps/edr-freight-api/src/modules/service-types/interfaces/service-types.repository.interface.ts rename to apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts index 8d4efd672..49c7f08e3 100644 --- a/apps/edr-freight-api/src/modules/service-types/interfaces/service-types.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts @@ -1,9 +1,9 @@ -import { FindManyOptions } from "typeorm"; - -import { ServiceType } from "../entities/service-type.entity"; +import { FindManyOptions } from 'typeorm'; +import { ServiceType } from '../entities/service-type.entity'; export interface IServiceTypesRepository { findById(id: string): Promise; + findByCode(code: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]>; create(data: Partial): Promise; @@ -11,4 +11,4 @@ export interface IServiceTypesRepository { softDelete(id: string): Promise; } -export const SERVICE_TYPES_REPOSITORY = Symbol("SERVICE_TYPES_REPOSITORY"); +export const SERVICE_TYPES_REPOSITORY = Symbol('SERVICE_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts new file mode 100644 index 000000000..03c76f408 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { SurchargeType } from '../entities/surcharge-type.entity'; + +export interface ISurchargeTypesRepository { + findById(id: string): Promise; + findByCode(code: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharges.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharges.repository.interface.ts new file mode 100644 index 000000000..96243ba34 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharges.repository.interface.ts @@ -0,0 +1,14 @@ +import { FindManyOptions } from 'typeorm'; +import { Surcharge } from '../entities/surcharge.entity'; + +export interface ISurchargesRepository { + findById(id: string): Promise; + findByTypeCode(typeCode: string): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[Surcharge[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const SURCHARGES_REPOSITORY = Symbol('SURCHARGES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts new file mode 100644 index 000000000..84216841d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -0,0 +1,17 @@ +import { FindManyOptions } from 'typeorm'; +import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; + +export interface IWeightLimitRulesRepository { + findById(id: string): Promise; + findActiveByContainerTypeAndDirection( + sizeCode: string, + tradeDirection: string, + ): Promise; + findAll(options?: FindManyOptions): Promise; + findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const WEIGHT_LIMIT_RULES_REPOSITORY = Symbol('WEIGHT_LIMIT_RULES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts new file mode 100644 index 000000000..496c2ce7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { CargoType } from '../entities/cargo-type.entity'; +import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface'; + +@Injectable() +export class CargoTypesRepository implements ICargoTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(CargoType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id }, relations: { parent: true } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts new file mode 100644 index 000000000..726fa0f37 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ContainerType } from '../entities/container-type.entity'; +import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface'; + +@Injectable() +export class ContainerTypesRepository implements IContainerTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ContainerType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findBySizeCode(sizeCode: string): Promise { + return this.repo.findOne({ where: { sizeCode } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[ContainerType[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-rules.repository.ts new file mode 100644 index 000000000..fa51de65a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-rules.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { PriorityRule } from '../entities/priority-rule.entity'; +import { IPriorityRulesRepository } from '../interfaces/priority-rules.repository.interface'; + +@Injectable() +export class PriorityRulesRepository implements IPriorityRulesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(PriorityRule); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findAllActive(): Promise { + return this.repo.find({ where: { isActive: true } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[PriorityRule[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts new file mode 100644 index 000000000..5e5f88b0a --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { ServiceType } from '../entities/service-type.entity'; +import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface'; + +@Injectable() +export class ServiceTypesRepository implements IServiceTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(ServiceType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts new file mode 100644 index 000000000..81dd89c1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { SurchargeType } from '../entities/surcharge-type.entity'; +import { ISurchargeTypesRepository } from '../interfaces/surcharge-types.repository.interface'; + +@Injectable() +export class SurchargeTypesRepository implements ISurchargeTypesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(SurchargeType); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id } }); + } + + findByCode(code: string): Promise { + return this.repo.findOne({ where: { code } }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharges.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharges.repository.ts new file mode 100644 index 000000000..b78cfc482 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharges.repository.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { Surcharge } from '../entities/surcharge.entity'; +import { ISurchargesRepository } from '../interfaces/surcharges.repository.interface'; + +@Injectable() +export class SurchargesRepository implements ISurchargesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(Surcharge); + } + + findById(id: string): Promise { + return this.repo.findOne({ where: { id }, relations: { surchargeType: true } }); + } + + findByTypeCode(typeCode: string): Promise { + return this.repo.findOne({ + where: { isActive: true, surchargeType: { code: typeCode } }, + relations: { surchargeType: true }, + }); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[Surcharge[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts new file mode 100644 index 000000000..5966c0ceb --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; +import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface'; + +@Injectable() +export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(WeightLimitRule); + } + + findById(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: { containerType: true, surcharge: { surchargeType: true } }, + }); + } + + findActiveByContainerTypeAndDirection( + sizeCode: string, + tradeDirection: string, + ): Promise { + return this.repo + .createQueryBuilder('rule') + .innerJoinAndSelect('rule.containerType', 'ct') + .leftJoinAndSelect('rule.surcharge', 'surcharge') + .leftJoinAndSelect('surcharge.surchargeType', 'surchargeType') + .where('ct.size_code = :sizeCode', { sizeCode }) + .andWhere('(rule.trade_direction = :dir OR rule.trade_direction = :both)', { + dir: tradeDirection, + both: 'BOTH', + }) + .andWhere('rule.is_active = true') + .getMany(); + } + + findAll(options?: FindManyOptions): Promise { + return this.repo.find(options); + } + + findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]> { + return this.repo.findAndCount(options); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + return this.repo.save(entity); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts new file mode 100644 index 000000000..153fa0b9b --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -0,0 +1,106 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { CargoType } from './entities/cargo-type.entity'; +import { ContainerType } from './entities/container-type.entity'; +import { PriorityRule } from './entities/priority-rule.entity'; +import { Surcharge } from './entities/surcharge.entity'; +import { SurchargeType } from './entities/surcharge-type.entity'; +import { ServiceType } from './entities/service-type.entity'; +import { WeightLimitRule } from './entities/weight-limit-rule.entity'; + +import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface'; +import { CONTAINER_TYPES_REPOSITORY } from './interfaces/container-types.repository.interface'; +import { PRIORITY_RULES_REPOSITORY } from './interfaces/priority-rules.repository.interface'; +import { SURCHARGES_REPOSITORY } from './interfaces/surcharges.repository.interface'; +import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface'; +import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; +import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; + +import { CargoTypesRepository } from './repositories/cargo-types.repository'; +import { ContainerTypesRepository } from './repositories/container-types.repository'; +import { PriorityRulesRepository } from './repositories/priority-rules.repository'; +import { SurchargesRepository } from './repositories/surcharges.repository'; +import { SurchargeTypesRepository } from './repositories/surcharge-types.repository'; +import { ServiceTypesRepository } from './repositories/service-types.repository'; +import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; + +import { CargoTypesService } from './services/cargo-types.service'; +import { ContainerTypesService } from './services/container-types.service'; +import { PriorityRulesService } from './services/priority-rules.service'; +import { SurchargesService } from './services/surcharges.service'; +import { SurchargeTypesService } from './services/surcharge-types.service'; +import { ServiceTypesService } from './services/service-types.service'; +import { WeightLimitRulesService } from './services/weight-limit-rules.service'; + +import { CargoTypesController } from './controllers/cargo-types.controller'; +import { ContainerTypesController } from './controllers/container-types.controller'; +import { PriorityRulesController } from './controllers/priority-rules.controller'; +import { SurchargesController } from './controllers/surcharges.controller'; +import { SurchargeTypesController } from './controllers/surcharge-types.controller'; +import { ServiceTypesController } from './controllers/service-types.controller'; +import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; + +import { RuleEngineService } from './rule-engine.service'; + +@Global() +@Module({ + imports: [ + TypeOrmModule.forFeature([ + CargoType, + ContainerType, + PriorityRule, + Surcharge, + SurchargeType, + ServiceType, + WeightLimitRule, + ]), + ], + controllers: [ + CargoTypesController, + ContainerTypesController, + PriorityRulesController, + SurchargesController, + SurchargeTypesController, + ServiceTypesController, + WeightLimitRulesController, + ], + providers: [ + // Repositories + CargoTypesRepository, + { provide: CARGO_TYPES_REPOSITORY, useExisting: CargoTypesRepository }, + ContainerTypesRepository, + { provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository }, + PriorityRulesRepository, + { provide: PRIORITY_RULES_REPOSITORY, useExisting: PriorityRulesRepository }, + SurchargesRepository, + { provide: SURCHARGES_REPOSITORY, useExisting: SurchargesRepository }, + SurchargeTypesRepository, + { provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository }, + ServiceTypesRepository, + { provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository }, + WeightLimitRulesRepository, + { provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository }, + // CRUD services + CargoTypesService, + ContainerTypesService, + PriorityRulesService, + SurchargesService, + SurchargeTypesService, + ServiceTypesService, + WeightLimitRulesService, + // Evaluation engine + RuleEngineService, + ], + exports: [ + RuleEngineService, + CargoTypesService, + ServiceTypesService, + ContainerTypesService, + SurchargeTypesService, + SurchargesService, + WeightLimitRulesService, + PriorityRulesService, + ], +}) +export class RuleEngineModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts new file mode 100644 index 000000000..3b08fd1bf --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -0,0 +1,196 @@ +import { Inject, Injectable, BadRequestException } from '@nestjs/common'; +import { Freight } from '@edr/types'; +import { Booking } from '../bookings/entities/booking.entity'; +import { + ICargoTypesRepository, + CARGO_TYPES_REPOSITORY, +} from './interfaces/cargo-types.repository.interface'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from './interfaces/service-types.repository.interface'; +import { + ISurchargesRepository, + SURCHARGES_REPOSITORY, +} from './interfaces/surcharges.repository.interface'; +import { + IWeightLimitRulesRepository, + WEIGHT_LIMIT_RULES_REPOSITORY, +} from './interfaces/weight-limit-rules.repository.interface'; +import { + IPriorityRulesRepository, + PRIORITY_RULES_REPOSITORY, +} from './interfaces/priority-rules.repository.interface'; + +export interface AppliedSurcharge { + feeName: string; + rate: number; + currency: string; + calculationMethod: Freight.CalculationMethod; + applyToRail: boolean; + applyToFirstMile: boolean; + applyToLastMile: boolean; +} + +export interface RuleEvaluationResult { + priorityScore: number; + appliedSurcharges: AppliedSurcharge[]; + warnings: string[]; + hardBlocked: string[]; + requiresDirectorApproval: boolean; +} + +@Injectable() +export class RuleEngineService { + constructor( + @Inject(CARGO_TYPES_REPOSITORY) + private readonly cargoTypesRepo: ICargoTypesRepository, + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly serviceTypesRepo: IServiceTypesRepository, + @Inject(SURCHARGES_REPOSITORY) + private readonly surchargesRepo: ISurchargesRepository, + @Inject(WEIGHT_LIMIT_RULES_REPOSITORY) + private readonly weightLimitRulesRepo: IWeightLimitRulesRepository, + @Inject(PRIORITY_RULES_REPOSITORY) + private readonly priorityRulesRepo: IPriorityRulesRepository, + ) {} + + /** + * Evaluate all rule engine rules against a booking snapshot. + * Returns the computed priority score, surcharges to apply, warnings, + * hard-block messages, and whether director approval is required. + * Callers must throw BadRequestException if hardBlocked is non-empty. + */ + async evaluate( + booking: Pick< + Booking, + | 'freightType' + | 'serviceType' + | 'paymentCurrency' + | 'cargoTotalWeightVgm' + | 'tradeDirection' + | 'isHazardous' + | 'isRefrigerated' + | 'containers' + >, + ): Promise { + const warnings: string[] = []; + const hardBlocked: string[] = []; + const appliedSurcharges: AppliedSurcharge[] = []; + let priorityScore = 0; + let requiresDirectorApproval = false; + + // ── 1. Cargo routing ───────────────────────────────────────────────── + // Look up CargoType by code to determine director-approval routing. + if (booking.freightType) { + const cargoType = await this.cargoTypesRepo.findByCode(booking.freightType); + if (cargoType?.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + } + + // ── 2. Weight-limit check ──────────────────────────────────────────── + // For each container group in the booking, find matching active rules + // and check whether the per-container VGM exceeds the max weight. + const containers = booking.containers ?? []; + for (const container of containers) { + const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeAndDirection( + container.type, + booking.tradeDirection, + ); + + for (const rule of rules) { + if (container.vgm > rule.maxWeightTons) { + const msg = + `${container.type} container VGM ${container.vgm}t exceeds max ` + + `${rule.maxWeightTons}t (${booking.tradeDirection})`; + + if (rule.exceededAction === Freight.ExceededAction.HARD_BLOCK) { + hardBlocked.push(msg); + } else { + warnings.push(msg); + } + + if (rule.surcharge) { + appliedSurcharges.push(this.mapSurcharge(rule.surcharge)); + } + } else if (container.vgm > rule.warningThresholdTons) { + warnings.push( + `${container.type} container VGM ${container.vgm}t is approaching limit ` + + `of ${rule.maxWeightTons}t (${booking.tradeDirection})`, + ); + } + } + } + + // ── 3. Surcharge flags ─────────────────────────────────────────────── + if (booking.isHazardous) { + const surcharge = await this.surchargesRepo.findByTypeCode('HAZARDOUS'); + if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge)); + } + + if (booking.isRefrigerated) { + const surcharge = await this.surchargesRepo.findByTypeCode('REFRIGERATED'); + if (surcharge) appliedSurcharges.push(this.mapSurcharge(surcharge)); + } + + // ── 4. Priority scoring ────────────────────────────────────────────── + const priorityRules = await this.priorityRulesRepo.findAllActive(); + + for (const rule of priorityRules) { + switch (rule.priorityType) { + case Freight.PriorityType.USD_PAYER: + if (booking.paymentCurrency === 'USD') { + priorityScore += rule.bonusPoints; + } + break; + + case Freight.PriorityType.RAIL_AND_FORWARDING: { + // Read bonus points from the matching ServiceType DB row + const serviceType = await this.serviceTypesRepo.findByCode(booking.serviceType); + if (serviceType && serviceType.priorityBonusPoints > 0) { + priorityScore += serviceType.priorityBonusPoints; + } else if (booking.serviceType === 'RAIL_AND_FORWARDING') { + // Fall back to the rule's own bonus_points if no ServiceType found + priorityScore += rule.bonusPoints; + } + break; + } + + case Freight.PriorityType.HIGH_VOLUME_SHIPMENT: + if (booking.cargoTotalWeightVgm >= 300) { + priorityScore += rule.bonusPoints; + } + break; + + case Freight.PriorityType.GOVERNMENT_ACCOUNT: + // TODO: integrate customer accountTier — evaluate when Customer entity is extended + break; + } + } + + return { priorityScore, appliedSurcharges, warnings, hardBlocked, requiresDirectorApproval }; + } + + /** + * Guard helper — throws BadRequestException if hardBlocked is non-empty. + * Call this immediately after evaluate() in BookingsService. + */ + assertNoHardBlocks(result: RuleEvaluationResult): void { + if (result.hardBlocked.length > 0) { + throw new BadRequestException(result.hardBlocked.join('; ')); + } + } + + private mapSurcharge(s: { feeName: string; rate: number; currency: string; calculationMethod: Freight.CalculationMethod; applyToRail: boolean; applyToFirstMile: boolean; applyToLastMile: boolean }): AppliedSurcharge { + return { + feeName: s.feeName, + rate: s.rate, + currency: s.currency, + calculationMethod: s.calculationMethod, + applyToRail: s.applyToRail, + applyToFirstMile: s.applyToFirstMile, + applyToLastMile: s.applyToLastMile, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts new file mode 100644 index 000000000..3ac0f3e67 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -0,0 +1,102 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ILike } from 'typeorm'; +import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; +import { CargoType } from '../entities/cargo-type.entity'; +import { + CARGO_TYPES_REPOSITORY, + ICargoTypesRepository, +} from '../interfaces/cargo-types.repository.interface'; + +@Injectable() +export class CargoTypesService { + constructor( + @Inject(CARGO_TYPES_REPOSITORY) + private readonly repository: ICargoTypesRepository, + ) {} + + /** List cargo types with pagination and optional filtering. */ + async findAll(filter: { + isActive?: boolean; + requiresDirectorApproval?: boolean; + parentGroupId?: string; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval; + if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId; + if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`); + + const [data, total] = await this.repository.findAndCount({ + where, + order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + relations: { parent: true }, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single cargo type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Cargo type ${id} not found`); + return entity; + } + + /** Get a cargo type by code. */ + async findByCode(code: string): Promise { + return this.repository.findByCode(code); + } + + /** Create a new cargo type. */ + async create(dto: CreateCargoTypeDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) throw new ConflictException(`Cargo type with code "${dto.code}" already exists`); + if (dto.parentGroupId) { + const parent = await this.repository.findById(dto.parentGroupId); + if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); + } + return this.repository.create({ + code: dto.code, + cargoTypeName: dto.cargoTypeName, + parentGroupId: dto.parentGroupId ?? null, + showFreeTextBox: dto.showFreeTextBox ?? false, + requiresDirectorApproval: dto.requiresDirectorApproval ?? false, + isActive: dto.isActive ?? true, + displayOrder: dto.displayOrder ?? 1, + }); + } + + /** Update an existing cargo type. */ + async update(id: string, dto: UpdateCargoTypeDto): Promise { + await this.findById(id); + if (dto.code) { + const conflict = await this.repository.findByCode(dto.code); + if (conflict && conflict.id !== id) { + throw new ConflictException(`Cargo type with code "${dto.code}" already exists`); + } + } + if (dto.parentGroupId) { + if (dto.parentGroupId === id) throw new ConflictException('A cargo type cannot be its own parent'); + const parent = await this.repository.findById(dto.parentGroupId); + if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); + return updated; + } + + /** Soft-delete a cargo type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts new file mode 100644 index 000000000..9688d16ad --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -0,0 +1,75 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; +import { ContainerType } from '../entities/container-type.entity'; +import { + CONTAINER_TYPES_REPOSITORY, + IContainerTypesRepository, +} from '../interfaces/container-types.repository.interface'; + +@Injectable() +export class ContainerTypesService { + constructor( + @Inject(CONTAINER_TYPES_REPOSITORY) + private readonly repository: IContainerTypesRepository, + ) {} + + /** List container types with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { sizeCode: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single container type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Container type ${id} not found`); + return entity; + } + + /** Create a new container type. */ + async create(dto: CreateContainerTypeDto): Promise { + const existing = await this.repository.findBySizeCode(dto.sizeCode); + if (existing) throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`); + return this.repository.create({ + sizeCode: dto.sizeCode, + description: dto.description ?? null, + containersPerWagon: dto.containersPerWagon, + isActive: dto.isActive ?? true, + }); + } + + /** Update an existing container type. */ + async update(id: string, dto: UpdateContainerTypeDto): Promise { + await this.findById(id); + if (dto.sizeCode) { + const conflict = await this.repository.findBySizeCode(dto.sizeCode); + if (conflict && conflict.id !== id) { + throw new ConflictException(`Container type with sizeCode "${dto.sizeCode}" already exists`); + } + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Container type ${id} not found`); + return updated; + } + + /** Soft-delete a container type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts new file mode 100644 index 000000000..9f4add791 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rules.service.ts @@ -0,0 +1,73 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto'; +import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto'; +import { PriorityRule } from '../entities/priority-rule.entity'; +import { + IPriorityRulesRepository, + PRIORITY_RULES_REPOSITORY, +} from '../interfaces/priority-rules.repository.interface'; + +@Injectable() +export class PriorityRulesService { + constructor( + @Inject(PRIORITY_RULES_REPOSITORY) + private readonly repository: IPriorityRulesRepository, + ) {} + + /** List priority rules with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: PriorityRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { priorityType: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single priority rule by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Priority rule ${id} not found`); + return entity; + } + + /** Create a new priority rule. */ + async create(dto: CreatePriorityRuleDto): Promise { + const existing = await this.repository.findAll({ where: { priorityType: dto.priorityType } }); + if (existing.length > 0) { + throw new ConflictException(`Priority rule for type "${dto.priorityType}" already exists`); + } + return this.repository.create({ + priorityType: dto.priorityType, + ruleName: dto.ruleName, + description: dto.description ?? null, + activationCondition: dto.activationCondition ?? null, + bonusPoints: dto.bonusPoints, + isActive: dto.isActive ?? false, + }); + } + + /** Update an existing priority rule. */ + async update(id: string, dto: UpdatePriorityRuleDto): Promise { + await this.findById(id); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Priority rule ${id} not found`); + return updated; + } + + /** Soft-delete a priority rule. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts new file mode 100644 index 000000000..011cf0e95 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -0,0 +1,93 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ILike } from 'typeorm'; +import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; +import { ServiceType } from '../entities/service-type.entity'; +import { + IServiceTypesRepository, + SERVICE_TYPES_REPOSITORY, +} from '../interfaces/service-types.repository.interface'; + +@Injectable() +export class ServiceTypesService { + constructor( + @Inject(SERVICE_TYPES_REPOSITORY) + private readonly repository: IServiceTypesRepository, + ) {} + + /** List service types with pagination and optional filtering. */ + async findAll(filter: { + isActive?: boolean; + canBeBookedAlone?: boolean; + search?: string; + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone; + if (filter.search) where.serviceName = ILike(`%${filter.search}%`); + + const [data, total] = await this.repository.findAndCount({ + where, + order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single service type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Service type ${id} not found`); + return entity; + } + + /** Get a service type by code. */ + async findByCode(code: string): Promise { + return this.repository.findByCode(code); + } + + /** Create a new service type. */ + async create(dto: CreateServiceTypeDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) throw new ConflictException(`Service type with code "${dto.code}" already exists`); + return this.repository.create({ + code: dto.code, + serviceName: dto.serviceName, + description: dto.description ?? null, + canBeBookedAlone: dto.canBeBookedAlone ?? true, + includesFirstMile: dto.includesFirstMile ?? false, + includesLastMile: dto.includesLastMile ?? false, + includesCustoms: dto.includesCustoms ?? false, + priorityBonusPoints: dto.priorityBonusPoints ?? 0, + isActive: dto.isActive ?? true, + displayOrder: dto.displayOrder ?? 1, + }); + } + + /** Update an existing service type. */ + async update(id: string, dto: UpdateServiceTypeDto): Promise { + await this.findById(id); + if (dto.code) { + const conflict = await this.repository.findByCode(dto.code); + if (conflict && conflict.id !== id) { + throw new ConflictException(`Service type with code "${dto.code}" already exists`); + } + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Service type ${id} not found`); + return updated; + } + + /** Soft-delete a service type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts new file mode 100644 index 000000000..986c0081d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts @@ -0,0 +1,75 @@ +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; +import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; +import { SurchargeType } from '../entities/surcharge-type.entity'; +import { + ISurchargeTypesRepository, + SURCHARGE_TYPES_REPOSITORY, +} from '../interfaces/surcharge-types.repository.interface'; + +@Injectable() +export class SurchargeTypesService { + constructor( + @Inject(SURCHARGE_TYPES_REPOSITORY) + private readonly repository: ISurchargeTypesRepository, + ) {} + + /** List surcharge types with pagination. */ + async findAll(filter: { + isActive?: boolean; + page?: number; + pageSize?: number; + }): Promise<{ data: SurchargeType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + + const [data, total] = await this.repository.findAndCount({ + where, + order: { name: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single surcharge type by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Surcharge type ${id} not found`); + return entity; + } + + /** Create a new surcharge type. */ + async create(dto: CreateSurchargeTypeDto): Promise { + const existing = await this.repository.findByCode(dto.code); + if (existing) throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`); + return this.repository.create({ + code: dto.code, + name: dto.name, + description: dto.description ?? null, + isActive: dto.isActive ?? true, + }); + } + + /** Update an existing surcharge type. */ + async update(id: string, dto: UpdateSurchargeTypeDto): Promise { + await this.findById(id); + if (dto.code) { + const conflict = await this.repository.findByCode(dto.code); + if (conflict && conflict.id !== id) { + throw new ConflictException(`Surcharge type with code "${dto.code}" already exists`); + } + } + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`); + return updated; + } + + /** Soft-delete a surcharge type. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharges.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharges.service.ts new file mode 100644 index 000000000..f584d86d2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharges.service.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateSurchargeDto } from '../dto/create-surcharge.dto'; +import { UpdateSurchargeDto } from '../dto/update-surcharge.dto'; +import { Surcharge } from '../entities/surcharge.entity'; +import { + ISurchargesRepository, + SURCHARGES_REPOSITORY, +} from '../interfaces/surcharges.repository.interface'; + +@Injectable() +export class SurchargesService { + constructor( + @Inject(SURCHARGES_REPOSITORY) + private readonly repository: ISurchargesRepository, + ) {} + + /** List surcharges with pagination. */ + async findAll(filter: { + isActive?: boolean; + surchargeTypeId?: string; + page?: number; + pageSize?: number; + }): Promise<{ data: Surcharge[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + if (filter.surchargeTypeId) where.surchargeTypeId = filter.surchargeTypeId; + + const [data, total] = await this.repository.findAndCount({ + where, + relations: { surchargeType: true }, + order: { feeName: 'ASC' }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single surcharge by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Surcharge ${id} not found`); + return entity; + } + + /** Create a new surcharge. */ + async create(dto: CreateSurchargeDto): Promise { + return this.repository.create({ + surchargeTypeId: dto.surchargeTypeId, + feeName: dto.feeName, + triggerDescription: dto.triggerDescription ?? null, + calculationMethod: dto.calculationMethod, + rate: dto.rate, + currency: dto.currency, + applyToRail: dto.applyToRail ?? false, + applyToFirstMile: dto.applyToFirstMile ?? false, + applyToLastMile: dto.applyToLastMile ?? false, + isActive: dto.isActive ?? true, + }); + } + + /** Update an existing surcharge. */ + async update(id: string, dto: UpdateSurchargeDto): Promise { + await this.findById(id); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Surcharge ${id} not found`); + return updated; + } + + /** Soft-delete a surcharge. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts new file mode 100644 index 000000000..33da4a7b9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -0,0 +1,78 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; +import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; +import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; +import { + IWeightLimitRulesRepository, + WEIGHT_LIMIT_RULES_REPOSITORY, +} from '../interfaces/weight-limit-rules.repository.interface'; + +@Injectable() +export class WeightLimitRulesService { + constructor( + @Inject(WEIGHT_LIMIT_RULES_REPOSITORY) + private readonly repository: IWeightLimitRulesRepository, + ) {} + + /** List weight limit rules with pagination. */ + async findAll(filter: { + isActive?: boolean; + containerTypeId?: string; + page?: number; + pageSize?: number; + }): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const where: Record = {}; + if (filter.isActive !== undefined) where.isActive = filter.isActive; + if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId; + + const [data, total] = await this.repository.findAndCount({ + where, + relations: { containerType: true, surcharge: { surchargeType: true } }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + } + + /** Get a single weight limit rule by ID. */ + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Weight limit rule ${id} not found`); + return entity; + } + + /** Create a new weight limit rule. */ + async create(dto: CreateWeightLimitRuleDto): Promise { + if (dto.warningThresholdTons > dto.maxWeightTons) { + throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons'); + } + return this.repository.create({ + containerTypeId: dto.containerTypeId, + tradeDirection: dto.tradeDirection, + maxWeightTons: dto.maxWeightTons, + warningThresholdTons: dto.warningThresholdTons, + exceededAction: dto.exceededAction, + surchargeId: dto.surchargeId ?? null, + isActive: dto.isActive ?? true, + }); + } + + /** Update an existing weight limit rule. */ + async update(id: string, dto: UpdateWeightLimitRuleDto): Promise { + const existing = await this.findById(id); + const warning = dto.warningThresholdTons ?? existing.warningThresholdTons; + const max = dto.maxWeightTons ?? existing.maxWeightTons; + if (warning > max) throw new BadRequestException('warningThresholdTons must be ≤ maxWeightTons'); + const updated = await this.repository.update(id, dto); + if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`); + return updated; + } + + /** Soft-delete a weight limit rule. */ + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/service-types/dto/create-service-type.dto.ts b/apps/edr-freight-api/src/modules/service-types/dto/create-service-type.dto.ts deleted file mode 100644 index c4da74794..000000000 --- a/apps/edr-freight-api/src/modules/service-types/dto/create-service-type.dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from "class-validator"; - -export class CreateServiceTypeDto { - @ApiProperty({ description: "Service type name", maxLength: 255 }) - @IsString() - @MaxLength(255) - serviceName!: string; - - @ApiPropertyOptional({ description: "Detailed description of the service" }) - @IsOptional() - @IsString() - description?: string; - - @ApiPropertyOptional({ description: "Can be booked alone", default: true }) - @IsOptional() - @IsBoolean() - canBeBookedAlone?: boolean = true; - - @ApiPropertyOptional({ description: "Includes first mile service", default: false }) - @IsOptional() - @IsBoolean() - includesFirstMile?: boolean = false; - - @ApiPropertyOptional({ description: "Includes last mile service", default: false }) - @IsOptional() - @IsBoolean() - includesLastMile?: boolean = false; - - @ApiPropertyOptional({ description: "Includes customs clearance", default: false }) - @IsOptional() - @IsBoolean() - includesCustoms?: boolean = false; - - @ApiPropertyOptional({ description: "Priority bonus points for booking priority", default: 0 }) - @IsOptional() - @IsInt() - @Min(0) - priorityBonusPoints?: number = 0; - - @ApiPropertyOptional({ description: "Is the service type active", default: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean = true; - - @ApiPropertyOptional({ description: "Display order for UI", default: 1 }) - @IsOptional() - @IsInt() - @Min(1) - displayOrder?: number = 1; -} diff --git a/apps/edr-freight-api/src/modules/service-types/dto/filter-service-type.dto.ts b/apps/edr-freight-api/src/modules/service-types/dto/filter-service-type.dto.ts deleted file mode 100644 index cd0992c96..000000000 --- a/apps/edr-freight-api/src/modules/service-types/dto/filter-service-type.dto.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform, Type } from "class-transformer"; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; - -export class FilterServiceTypeDto { - @ApiPropertyOptional({ description: "Filter by active status" }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) - isActive?: boolean; - - @ApiPropertyOptional({ description: "Filter by can be booked alone" }) - @IsOptional() - @IsBoolean() - @Transform(({ value }) => value === "true" || value === true) - canBeBookedAlone?: boolean; - - @ApiPropertyOptional({ description: "Search by service name (case-insensitive)" }) - @IsOptional() - @IsString() - search?: string; - - @ApiPropertyOptional({ enum: ["serviceName", "displayOrder", "createdAt"], default: "displayOrder" }) - @IsOptional() - @IsIn(["serviceName", "displayOrder", "createdAt"]) - sortBy?: string = "displayOrder"; - - @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) - @IsOptional() - @IsIn(["ASC", "DESC"]) - sortOrder?: "ASC" | "DESC" = "ASC"; - - @ApiPropertyOptional({ description: "Page number", default: 1, minimum: 1 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page?: number = 1; - - @ApiPropertyOptional({ description: "Items per page", default: 20, minimum: 1 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - pageSize?: number = 20; -} diff --git a/apps/edr-freight-api/src/modules/service-types/dto/update-service-type.dto.ts b/apps/edr-freight-api/src/modules/service-types/dto/update-service-type.dto.ts deleted file mode 100644 index 25cd87146..000000000 --- a/apps/edr-freight-api/src/modules/service-types/dto/update-service-type.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PartialType } from "@nestjs/mapped-types"; - -import { CreateServiceTypeDto } from "./create-service-type.dto"; - -export class UpdateServiceTypeDto extends PartialType(CreateServiceTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts b/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts deleted file mode 100644 index 756ece6e0..000000000 --- a/apps/edr-freight-api/src/modules/service-types/entities/service-type.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { BaseEntity } from "@edr/api-common"; -import { Column, Entity, Index } from "typeorm"; - -@Entity({ schema: "freight", name: "service_types" }) -@Index(["isActive"]) -@Index(["displayOrder"]) -export class ServiceType extends BaseEntity { - @Column({ name: "service_name", type: "varchar", length: 255, nullable: false }) - serviceName!: string; - - @Column({ name: "description", type: "text", nullable: true }) - description?: string | null; - - @Column({ name: "can_be_booked_alone", type: "boolean", default: true }) - canBeBookedAlone!: boolean; - - @Column({ name: "includes_first_mile", type: "boolean", default: false }) - includesFirstMile!: boolean; - - @Column({ name: "includes_last_mile", type: "boolean", default: false }) - includesLastMile!: boolean; - - @Column({ name: "includes_customs", type: "boolean", default: false }) - includesCustoms!: boolean; - - @Column({ name: "priority_bonus_points", type: "int", default: 0 }) - priorityBonusPoints!: number; - - @Column({ name: "is_active", type: "boolean", default: true }) - isActive!: boolean; - - @Column({ name: "display_order", type: "int", default: 1 }) - displayOrder!: number; -} diff --git a/apps/edr-freight-api/src/modules/service-types/service-types.controller.ts b/apps/edr-freight-api/src/modules/service-types/service-types.controller.ts deleted file mode 100644 index 90203a493..000000000 --- a/apps/edr-freight-api/src/modules/service-types/service-types.controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Patch, - Post, - Query, -} from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; - -import { CreateServiceTypeDto } from "./dto/create-service-type.dto"; -import { FilterServiceTypeDto } from "./dto/filter-service-type.dto"; -import { UpdateServiceTypeDto } from "./dto/update-service-type.dto"; -import { ServiceTypesService } from "./service-types.service"; - -@ApiTags("service-types") -@Controller("service-types") -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated -@ApiBearerAuth() -export class ServiceTypesController { - constructor(private readonly service: ServiceTypesService) {} - - @Get() - @ApiOperation({ - summary: "List service types", - description: "Paginated list with optional filtering by isActive, canBeBookedAlone, and name search.", - }) - findAll(@Query() filter: FilterServiceTypeDto) { - return this.service.findAll(filter); - } - - @Get(":id") - @ApiOperation({ summary: "Get a service type by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { - return this.service.findById(id); - } - - @Post() - @ApiOperation({ summary: "Create a new service type" }) - create(@Body() dto: CreateServiceTypeDto) { - return this.service.create(dto); - } - - @Patch(":id") - @ApiOperation({ summary: "Update a service type" }) - update( - @Param("id", ParseUUIDPipe) id: string, - @Body() dto: UpdateServiceTypeDto, - ) { - return this.service.update(id, dto); - } - - @Delete(":id") - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: "Soft-delete a service type" }) - remove(@Param("id", ParseUUIDPipe) id: string) { - return this.service.remove(id); - } -} diff --git a/apps/edr-freight-api/src/modules/service-types/service-types.module.ts b/apps/edr-freight-api/src/modules/service-types/service-types.module.ts deleted file mode 100644 index db4408af1..000000000 --- a/apps/edr-freight-api/src/modules/service-types/service-types.module.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { ServiceType } from "./entities/service-type.entity"; -import { SERVICE_TYPES_REPOSITORY } from "./interfaces/service-types.repository.interface"; -import { ServiceTypesRepository } from "./service-types.repository"; -import { ServiceTypesController } from "./service-types.controller"; -import { ServiceTypesService } from "./service-types.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([ServiceType])], - controllers: [ServiceTypesController], - providers: [ - ServiceTypesRepository, - { - provide: SERVICE_TYPES_REPOSITORY, - useExisting: ServiceTypesRepository, - }, - ServiceTypesService, - ], - exports: [ServiceTypesService], -}) -export class ServiceTypesModule {} diff --git a/apps/edr-freight-api/src/modules/service-types/service-types.repository.ts b/apps/edr-freight-api/src/modules/service-types/service-types.repository.ts deleted file mode 100644 index 299610e28..000000000 --- a/apps/edr-freight-api/src/modules/service-types/service-types.repository.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { BaseRepository } from "@edr/api-common"; -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { FindManyOptions, Repository } from "typeorm"; - -import { ServiceType } from "./entities/service-type.entity"; -import { IServiceTypesRepository } from "./interfaces/service-types.repository.interface"; - -@Injectable() -export class ServiceTypesRepository - extends BaseRepository - implements IServiceTypesRepository -{ - constructor( - @InjectRepository(ServiceType) - repository: Repository, - ) { - super(repository); - } - - override findById(id: string): Promise { - return this.repository.findOne({ - where: { id }, - }); - } - - override findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]> { - return this.repository.findAndCount(options); - } -} diff --git a/apps/edr-freight-api/src/modules/service-types/service-types.service.ts b/apps/edr-freight-api/src/modules/service-types/service-types.service.ts deleted file mode 100644 index b625eb72d..000000000 --- a/apps/edr-freight-api/src/modules/service-types/service-types.service.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Inject, Injectable, NotFoundException } from "@nestjs/common"; -import { ILike } from "typeorm"; - -import { CreateServiceTypeDto } from "./dto/create-service-type.dto"; -import { FilterServiceTypeDto } from "./dto/filter-service-type.dto"; -import { UpdateServiceTypeDto } from "./dto/update-service-type.dto"; -import { ServiceType } from "./entities/service-type.entity"; -import { - IServiceTypesRepository, - SERVICE_TYPES_REPOSITORY, -} from "./interfaces/service-types.repository.interface"; - -@Injectable() -export class ServiceTypesService { - constructor( - @Inject(SERVICE_TYPES_REPOSITORY) - private readonly repository: IServiceTypesRepository, - ) {} - - /** - * Find all service types with pagination and optional filtering. - */ - async findAll(filter: FilterServiceTypeDto): Promise<{ - data: ServiceType[]; - meta: { - total: number; - page: number; - pageSize: number; - totalPages: number; - }; - }> { - const where: Record = {}; - - if (filter.isActive !== undefined) { - where.isActive = filter.isActive; - } - - if (filter.canBeBookedAlone !== undefined) { - where.canBeBookedAlone = filter.canBeBookedAlone; - } - - if (filter.search) { - where.serviceName = ILike(`%${filter.search}%`); - } - - const [data, total] = await this.repository.findAndCount({ - where, - order: { [filter.sortBy!]: filter.sortOrder }, - skip: (filter.page! - 1) * filter.pageSize!, - take: filter.pageSize, - }); - - return { - data, - meta: { - total, - page: filter.page!, - pageSize: filter.pageSize!, - totalPages: Math.ceil(total / filter.pageSize!), - }, - }; - } - - /** - * Get a single service type by ID. - */ - async findById(id: string): Promise { - const entity = await this.repository.findById(id); - if (!entity) { - throw new NotFoundException(`Service type ${id} not found`); - } - return entity; - } - - /** - * Create a new service type. - */ - async create(dto: CreateServiceTypeDto): Promise { - return this.repository.create({ - serviceName: dto.serviceName, - description: dto.description ?? null, - canBeBookedAlone: dto.canBeBookedAlone ?? true, - includesFirstMile: dto.includesFirstMile ?? false, - includesLastMile: dto.includesLastMile ?? false, - includesCustoms: dto.includesCustoms ?? false, - priorityBonusPoints: dto.priorityBonusPoints ?? 0, - isActive: dto.isActive ?? true, - displayOrder: dto.displayOrder ?? 1, - }); - } - - /** - * Update an existing service type. - */ - async update(id: string, dto: UpdateServiceTypeDto): Promise { - await this.findById(id); - const updated = await this.repository.update(id, dto); - if (!updated) { - throw new NotFoundException(`Service type ${id} not found`); - } - return this.findById(id); - } - - /** - * Soft-delete a service type. - */ - async remove(id: string): Promise { - await this.findById(id); - await this.repository.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/seed/demo-users.seeder.ts b/apps/edr-freight-api/src/seed/demo-users.seeder.ts new file mode 100644 index 000000000..8d886e3aa --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-users.seeder.ts @@ -0,0 +1,213 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; +import { + Employee, + Organization, + Permission, + Role, + RolePermission, + User, + UserCredential, + UserRole, +} from "@tria-plc/iamapi-common"; +import { DataSource } from "typeorm"; + +const SEED_FLAG = "SEED_DEMO_USERS"; + +const DEMO_ORG_KEY = "demo_iam"; +const DEMO_ORG_NAME = { en: "Demo IAM" }; + +const DEMO_PERMISSIONS = [ + { key: "can:demo:user1", name: { en: "Can access demo user1" } }, + { key: "can:demo:user2", name: { en: "Can access demo user2" } }, +]; + +const DEMO_ROLES = [ + { key: "demo_user1", name: { en: "Demo User1" } }, + { key: "demo_user2", name: { en: "Demo User2" } }, +]; + +const DEMO_USERS = [ + { + email: "user@gmail.com", + username: "user", + name: { en: "Demo User 1" }, + roleKey: "demo_user1", + }, + { + email: "user2@gmail.com", + username: "user2", + name: { en: "Demo User 2" }, + roleKey: "demo_user2", + }, +]; + +@Injectable() +export class DemoUsersSeeder { + private readonly logger = new Logger(DemoUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + if (!shouldSeed) { + this.logger.log(`Skipping demo user seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + const organizationRepository = manager.getRepository(Organization); + const employeeRepository = manager.getRepository(Employee); + const permissionRepository = manager.getRepository(Permission); + const roleRepository = manager.getRepository(Role); + const rolePermissionRepository = manager.getRepository(RolePermission); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + + await organizationRepository.upsert( + { + key: DEMO_ORG_KEY, + name: DEMO_ORG_NAME, + // status defaults to ACTIVE in IAM entity + isGovernmentOrganization: true, + }, + { conflictPaths: { key: true } }, + ); + + const organization = await organizationRepository.findOne({ + where: { key: DEMO_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error("demo_org_seed_failed"); + } + + await permissionRepository.upsert(DEMO_PERMISSIONS, { + conflictPaths: { key: true }, + }); + + await roleRepository.upsert(DEMO_ROLES, { + conflictPaths: { key: true }, + }); + + const roles = await roleRepository.find({ where: DEMO_ROLES.map((r) => ({ key: r.key })) }); + const permissions = await permissionRepository.find({ + where: DEMO_PERMISSIONS.map((p) => ({ key: p.key })), + }); + + const roleByKey = new Map(roles.map((r) => [r.key, r])); + const permissionByKey = new Map(permissions.map((p) => [p.key, p])); + + const superAdminRole = await roleRepository.findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + const rolePermissionsToUpsert = [ + { + roleId: roleByKey.get("demo_user1")!.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: roleByKey.get("demo_user2")!.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ...(superAdminRole + ? ([ + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user1")!.id, + }, + { + roleId: superAdminRole.id, + permissionId: permissionByKey.get("can:demo:user2")!.id, + }, + ] as Array<{ roleId: string; permissionId: string }>) + : []), + ]; + + await rolePermissionRepository.upsert(rolePermissionsToUpsert, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + const hashedPassword = await hashPassword("12345678"); + + for (const demoUser of DEMO_USERS) { + const existingUser = await userRepository.findOne({ + where: { email: demoUser.email }, + select: { id: true, email: true }, + }); + + let user = existingUser; + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: demoUser.email, + username: demoUser.username, + name: demoUser.name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } + + // Ensure an active credential exists for login. + const activeCredentialExists = await userCredentialRepository.exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + // Login query requires a current employee in an ACTIVE organization. + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: demoUser.name, + }); + } + + const role = roleByKey.get(demoUser.roleKey); + if (!role) { + throw new Error(`missing_role:${demoUser.roleKey}`); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + } + }); + + this.logger.log( + "Seeded demo users + permissions (user@gmail.com, user2@gmail.com; permissions can:demo:user1/can:demo:user2)", + ); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 579603ed6..3ab4f2e54 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,7 +1,6 @@ import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; import { LayoutDashboard, Network, Paperclip, Settings } from "lucide-react"; - import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; import OverviewPage from "./pages/dashboard/OverviewPage"; @@ -12,6 +11,8 @@ import UserManagementPage from "./pages/dashboard/user-management/UserManagement import LoadingScreen from "./components/LoadingScreen"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; const sidebarItems: SidebarItem[] = [ { @@ -50,11 +51,73 @@ const sidebarItems: SidebarItem[] = [ } ]; +const hasPermission = ( + user: ReturnType["user"], + key: string, +) => { + if (!user) return false; + if (user.permissions?.some((p) => p.key === key)) return true; + return (user.employee ?? []).some((emp) => + (emp.positions ?? []).some((pos) => + (pos.permissions ?? []).some((p) => p.key === key), + ), + ); +}; const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); const { user, logout } = useAuth(); + const sidebarItems: SidebarItem[] = [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "User management", + href: "/dashboard/user-management", + icon: , + children: [ + { + label: "Employees", + href: "/dashboard/user-management/employees", + }, + { + label: "Permissions", + href: "/dashboard/user-management/permissions", + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + ], + }, + { + label: "Rule Engine", + href: "/dashboard/rule-engine", + icon: , + }, + ...(hasPermission(user, "can:demo:user1") + ? ([ + { + label: "User1", + href: "/dashboard/user1", + icon: , + }, + ] as SidebarItem[]) + : []), + ...(hasPermission(user, "can:demo:user2") + ? ([ + { + label: "User2", + href: "/dashboard/user2", + icon: , + }, + ] as SidebarItem[]) + : []), + ]; + const displayName = user?.name?.en || user?.username || user?.email || "User"; return ( @@ -98,9 +161,12 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType.tsx new file mode 100644 index 000000000..dff4d0c7c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ContractType.tsx @@ -0,0 +1,221 @@ +import { JSXElementConstructor, MouseEventHandler, ReactElement, ReactNode, SetStateAction, useState } from 'react'; + +export const ContractTypePage = () => { + const [expandedSections, setExpandedSections] = useState({ + contractType: true, + serviceType: false, + cargoType: false + }); + + const [contractTypes, setContractTypes] = useState([ + { id: 1, name: 'Shipper', description: 'Company that sends the freight' }, + { id: 2, name: 'Consignee', description: 'Company that receives the freight' }, + { id: 3, name: 'Third Party', description: 'Company that is neither the shipper nor the consignee but is involved in the freight process' } + ]); + + const [serviceTypes, setServiceTypes] = useState([ + { id: 1, name: 'Standard', description: 'Regular shipping service' }, + { id: 2, name: 'Express', description: 'Fast delivery service' }, + { id: 3, name: 'Economy', description: 'Cost-effective shipping option' } + ]); + + const [cargoTypes, setCargoTypes] = useState([ + { id: 1, name: 'General Cargo', description: 'Standard packaged goods' }, + { id: 2, name: 'Temperature Controlled', description: 'Goods requiring specific temperature' }, + { id: 3, name: 'Hazardous Materials', description: 'Dangerous goods requiring special handling' } + ]); + + const [newContractType, setNewContractType] = useState({ name: '', description: '' }); + const [newServiceType, setNewServiceType] = useState({ name: '', description: '' }); + const [newCargoType, setNewCargoType] = useState({ name: '', description: '' }); + const [showAddForms, setShowAddForms] = useState({ + contractType: false, + serviceType: false, + + cargoType: false + }); + + type SectionKey = 'contractType' | 'serviceType' | 'cargoType'; + + const toggleSection = (section: SectionKey) => { + setExpandedSections(prev => ({ + ...prev, + [section]: !prev[section] + })); + }; + + const toggleAddForm = (section: SectionKey) => { + setShowAddForms(prev => ({ + ...prev, + [section]: !prev[section] + })); + }; + + const handleAddContractType = () => { + if (newContractType.name && newContractType.description) { + setContractTypes([ + ...contractTypes, + { id: Date.now(), ...newContractType } + ]); + setNewContractType({ name: '', description: '' }); + toggleAddForm('contractType'); + } + }; + + const handleAddServiceType = () => { + if (newServiceType.name && newServiceType.description) { + setServiceTypes([ + ...serviceTypes, + { id: Date.now(), ...newServiceType } + ]); + setNewServiceType({ name: '', description: '' }); + toggleAddForm('serviceType'); + } + }; + + const handleAddCargoType = () => { + if (newCargoType.name && newCargoType.description) { + setCargoTypes([ + ...cargoTypes, + { id: Date.now(), ...newCargoType } + ]); + setNewCargoType({ name: '', description: '' }); + toggleAddForm('cargoType'); + } + }; + + const handleDelete = (type: string, id: number) => { + if (type === 'contract') { + setContractTypes(contractTypes.filter(item => item.id !== id)); + } else if (type === 'service') { + setServiceTypes(serviceTypes.filter(item => item.id !== id)); + } else if (type === 'cargo') { + setCargoTypes(cargoTypes.filter(item => item.id !== id)); + } + }; + + const handleEdit = (type: any, id: any) => { + // Implement edit functionality as needed + alert(`Edit ${type} type with id: ${id}`); + }; + + const renderTable = (title: string | number | boolean | ReactElement> | Iterable | null | undefined, types: any[], onAdd: { (): void; (): void; (): void; }, newItem: { name: any; description: any; }, setNewItem: { (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (value: SetStateAction<{ name: string; description: string; }>): void; (arg0: any): void; }, showAddForm: boolean, typeKey: string, addHandler: MouseEventHandler | undefined) => ( +
+
toggleSection(typeKey)} + > + + {expandedSections[typeKey] ? '▼' : '▶'} + +

{title}

+
+ + {expandedSections[typeKey] && ( +
+ + + {showAddForm && ( +
+

Add New {title.replace(' Types', '')}

+
+ setNewItem({ ...newItem, name: e.target.value })} + style={{ marginRight: '10px', padding: '5px' }} + /> + setNewItem({ ...newItem, description: e.target.value })} + style={{ marginRight: '10px', padding: '5px' }} + /> + + +
+
+ )} + + + + + + + + + + + + {types.map((type) => ( + + + + + + + ))} + +
IDNameDescriptionActions
{type.id}{type.name}{type.description} + + +
+
+ )} +
+ ); + + return ( +
+ {renderTable( + 'Contract Types', + contractTypes, + handleAddContractType, + newContractType, + setNewContractType, + showAddForms.contractType, + 'contractType', + handleAddContractType + )} + + {renderTable( + 'Service Types', + serviceTypes, + handleAddServiceType, + newServiceType, + setNewServiceType, + showAddForms.serviceType, + 'serviceType', + handleAddServiceType + )} + + {renderTable( + 'Cargo Types', + cargoTypes, + handleAddCargoType, + newCargoType, + setNewCargoType, + showAddForms.cargoType, + 'cargoType', + handleAddCargoType + )} +
+ ); +}; \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx new file mode 100644 index 000000000..e47cb95bd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser1Page.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; + +import { api } from "@/auth/http"; + +const DemoUser1Page = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + setLoading(true); + setError(null); + + try { + const response = await api.get("/test_user1"); + if (cancelled) return; + setData(response.data); + } catch (e: any) { + if (cancelled) return; + const message = + e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + "Request failed"; + setError(String(message)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

User1 Demo

+

+ Calls GET /api/test_user1 (requires{' '} + can:demo:user1). +

+ +
+ {loading ?

Loading...

: null} + {error ? ( +
+ {error} +
+ ) : null} + {!loading && !error ? ( +
+              {JSON.stringify(data, null, 2)}
+            
+ ) : null} +
+
+
+ ); +}; + +export default DemoUser1Page; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx new file mode 100644 index 000000000..5ef7ad172 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/demo/DemoUser2Page.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; + +import { api } from "@/auth/http"; + +const DemoUser2Page = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + setLoading(true); + setError(null); + + try { + const response = await api.get("/test_user2"); + if (cancelled) return; + setData(response.data); + } catch (e: any) { + if (cancelled) return; + const message = + e?.response?.data?.message || + e?.response?.data?.error || + e?.message || + "Request failed"; + setError(String(message)); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

User2 Demo

+

+ Calls GET /api/test_user2 (requires{' '} + can:demo:user2). +

+ +
+ {loading ?

Loading...

: null} + {error ? ( +
+ {error} +
+ ) : null} + {!loading && !error ? ( +
+              {JSON.stringify(data, null, 2)}
+            
+ ) : null} +
+
+
+ ); +}; + +export default DemoUser2Page; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngine.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngine.tsx new file mode 100644 index 000000000..96493948d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngine.tsx @@ -0,0 +1,14 @@ +import { ContractTypePage, } from "@/components/ruleEngine/ContractType"; + +export const RuleEnginePage = () => { + return
+

+ Rule Engine Page +

+ +
+ +
+ +
; +}; \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 968f0925d..647878972 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -7,109 +7,78 @@ import { } from "react-router-dom"; import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; import { - LayoutDashboard, - Users, CalendarCheck, - Package, MapPin, - Train, Receipt, FileText, - Settings, - UserCircle, - FileUp, - MapPinned, + Home, + Loader2, } from "lucide-react"; -import BookingsPage from "./pages/bookings/BookingsPage"; +import useAuth from "./hooks/useAuth"; + +import MyPortalPage from "./pages/MyPortalPage"; +import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; +import SignupPage from "./pages/accounts/SignupPage"; +import OnboardingPage from "./pages/accounts/OnboardingPage"; +import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; +import SetPasswordPage from "./pages/accounts/SetPasswordPage"; +import LoginPage from "./pages/accounts/LoginPage"; import MyBookings from "./pages/bookings/MyBookings"; import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; -import ConsignmentsPage from "./pages/consignments/ConsignmentsPage"; -import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage"; import TrackingPage from "./pages/tracking/TrackingPage"; import BillingPage from "./pages/billing/BillingPage"; -import TrainsPage from "./pages/trains/TrainsPage"; -import DashboardPage from "./pages/dashboard/DashboardPage"; -import { - IamLoginPage, - LoadingScreen, - useAuth, - useAuthUser, -} from "@tria-plc/iamui-common"; -import CustomersPage from "./pages/customers/CustomersPage"; -import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; -import NewCustomerPage from "./pages/customers/NewCustomerPage"; import DocumentsPage from "./pages/documents/DocumentsPage"; -import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage"; -import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage"; -import MyPortalPage from "./pages/portal/MyPortalPage"; -import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; -import SignupPage from "./pages/accounts/SignupPage"; -import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; -import SetPasswordPage from "./pages/accounts/SetPasswordPage"; -import Station from "./components/stations/Station"; +import { useEffect } from "react"; const sidebarItems: SidebarItem[] = [ - { label: "My Portal", href: "/", icon: }, - { label: "Dashboard", href: "/dashboard", icon: }, - { label: "Customers", href: "/customers", icon: }, + { label: "Home", href: "/", icon: }, { label: "My Bookings", href: "/bookings", icon: }, - { label: "Consignments", href: "/consignments", icon: }, { label: "Tracking", href: "/tracking", icon: }, - { label: "Stations", href: "/stations", icon: }, - { label: "Trains", href: "/trains", icon: }, { label: "Billing", href: "/billing", icon: }, { label: "Documents", href: "/documents", icon: }, - { label: "Dropdown Settings", href: "/admin/dropdowns", icon: }, - { - label: "File Upload Settings", - href: "/admin/file-uploads", - icon: , - }, ]; const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, loading } = useAuth(); - const { logout } = useAuthUser(); + const { user, isPending, logout, customer, customerQuery } = useAuth(); - if (loading) { - return ; + console.log({ customer, isPending, user }); + useEffect(() => { + if (!user) return; + // if (!user.hasSetPassword) navigate("/set-password"); + }, [user]); + + if (isPending) { + return ( +
+ +
+ ); } - if (user) { + if (!user) { return ( } /> + } /> } /> } /> } /> - } /> - {/* } /> */} + } /> ); } + if (user && !customer && !customerQuery.isPending) { + return ; + } + const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; - const handleLogout = () => { - logout(); - [ - "auth-token", - "refresh-token", - "auth-user", - "current-position-id", - "selected-position-id", - ].forEach((name) => { - document.cookie = `${name}=; Max-Age=0; path=/`; - }); - localStorage.clear(); - window.location.replace("/auth"); - }; - return ( { enableThemeToggle userName={displayName} userEmail={userEmail} - onLogout={handleLogout} + onLogout={logout} > - } /> } /> } /> - } /> - } /> - } /> - } /> } /> } /> - } /> - } /> } /> - } /> - } /> } /> } /> - } /> - } - /> - } /> } /> diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx new file mode 100644 index 000000000..9d023a3ec --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from "react"; +import { ShieldCheck, Train } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface AuthLayoutProps { + children: ReactNode; + parentClassName?: string; + contentClassName?: string; + left: { + badge: string; + title: string; + description: string; + features: string[]; + stats: { + label: string; + value: string; + footer: string; + progress: string; + }; + }; +} + +export default function AuthLayout({ + children, + parentClassName, + contentClassName, + left, +}: AuthLayoutProps) { + return ( +
+
+
+
+
+
+
+ +
+
+

EDR Freight

+

+ Railway Logistics Platform +

+
+
+
+
+ {left.badge} +
+

+ {left.title} +

+

+ {left.description} +

+
+
+ {left.features.map((item) => ( +
+
+ +
+ {item} +
+ ))} +
+
+
+
+
+
+
+ +
+
+

EDR Freight

+

+ Railway Logistics Platform +

+
+
+
{children}
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx new file mode 100644 index 000000000..e490a28ef --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx @@ -0,0 +1,43 @@ +import { Field, FieldLabel, FieldError, Input } from "@edr/ui-common"; + +interface PhoneInputProps { + disabled?: boolean; + countryCode?: React.ComponentProps; + phone?: React.ComponentProps; + countryCodeError?: { message?: string }; + phoneError?: { message?: string }; + label?: string; +} + +export default function PhoneInput({ + disabled, + countryCode: countryCodeProps, + phone: phoneProps, + countryCodeError, + phoneError, + label = "Phone Number", +}: PhoneInputProps) { + return ( + + {label} +
+ + +
+ +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/components/stations/Station.tsx b/apps/edr-freight-web/portal/src/components/stations/Station.tsx deleted file mode 100644 index da65b3094..000000000 --- a/apps/edr-freight-web/portal/src/components/stations/Station.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { useMemo, useState } from "react"; -import { - AlertCircle, - CircleOff, - Loader2, - MapPin, - Search, - TrainFront, -} from "lucide-react"; - -import Breadcrumbs from "@/components/Breadcrumbs"; -import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings"; -import type { DropdownOption } from "@/types/dropdownSettings"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - DataTable, - DataTableFooter, - Input, - type ColumnDef, - usePagination, -} from "@edr/ui-common"; - -const STATION_DROPDOWN_CODE = "stations_ter"; - -export default function Station() { - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [query, setQuery] = useState(""); - - const { data, isLoading, isError, error } = useDropdownSettingByCode( - STATION_DROPDOWN_CODE, - ); - - const stations = useMemo( - () => [...(data?.children ?? [])].sort((a, b) => a.order - b.order), - [data?.children], - ); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return stations; - - return stations.filter( - (station) => - station.label.toLowerCase().includes(q) || - station.value.toLowerCase().includes(q) || - (station.note ?? "").toLowerCase().includes(q), - ); - }, [query, stations]); - - const total = filtered.length; - const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); - const start = pagination.pageIndex * pagination.pageSize; - const end = Math.min(start + pagination.pageSize, total); - const paginatedData = useMemo( - () => filtered.slice(start, end), - [end, filtered, start], - ); - - const activeCount = stations.filter((station) => !station.disabled).length; - const disabledCount = stations.length - activeCount; - - const status: "loading" | "error" | "success" = isLoading - ? "loading" - : isError - ? "error" - : "success"; - - const columns: ColumnDef[] = [ - { - id: "station", - header: "Station", - cell: ({ row }) => { - const station = row.original; - return ( -
-
- -
-
-

{station.label}

-

- {station.note ?? "No station note"} -

-
-
- ); - }, - }, - { - id: "value", - header: "Code", - cell: ({ row }) => ( - - {row.original.value} - - ), - }, - { - accessorKey: "order", - header: "Order", - }, - { - id: "status", - header: "Status", - cell: ({ row }) => - row.original.disabled ? ( - - - Disabled - - ) : ( - - - Active - - ), - }, - ]; - - return ( -
-
- - - -
-

- Stations -

-

- Station options loaded from dropdown code{" "} - stations_ter. -

-
- -
- - { - setQuery(event.target.value); - setPagination({ - pageIndex: 0, - pageSize: pagination.pageSize, - }); - }} - placeholder="Search stations..." - className="pl-8!" - /> -
-
- -
- - - -
- - {isError ? ( - - - - Failed to load stations.{" "} - {error instanceof Error ? error.message : "Unknown error."} - - - ) : null} - - - - Station List - - All configured freight stations from the dropdown service. - - - - - {isLoading ? ( -
- - Loading stations... -
- ) : ( - - )} -
-
-
-
- ); -} - -function StationStat({ label, value }: { label: string; value: number }) { - return ( - - -
-

{label}

-

{value}

-
-
- -
-
-
- ); -} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index f22b31e39..84db5e2c2 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -1,21 +1,25 @@ export const URL_CONSTANTS = { AUTH: { - LOGIN: "/auth/login", - REGISTER: "/auth/register", - REFRESH_TOKEN: "/auth/refresh-token", - LOGOUT: "/auth/logout", + LOGIN: "/api/auth/login", + REGISTER: "/api/auth/register", + REFRESH_TOKEN: "/api/auth/refresh-token", + LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", }, USERS: { - SIGN_UP: "/api/auth/signup", - GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code", BASE: "/users", BY_ID: (id: string | number) => `/users/${id}`, + SIGN_UP: "/api/auth/signup", SET_PASSWORD: "/api/auth/set-password", - ME: "/api/auth/me" + ME: "/api/auth/me", + GENERATE_VERIFICATION_CODE: "/users/generate-verification-code", }, + OTP: { + SEND: "/api/otp/send", + VERIFY: "/api/otp/verify", + }, ROLES: { BASE: "/roles", BY_ID: (id: string | number) => `/roles/${id}`, @@ -68,11 +72,11 @@ export const URL_CONSTANTS = { BY_ID: (id: string | number) => `/customers/${id}`, BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, - + CUSTOMERS_API: { BASE: "/api/customers", BY_ID: (id: string) => `/api/customers/${id}`, - BY_USER_ID: (id: string) => `/api/customers/user/${id}` + BY_USER_ID: (id: string) => `/api/customers/user/${id}`, }, BOOKINGS: { @@ -81,9 +85,4 @@ export const URL_CONSTANTS = { CANCEL: (id: string | number) => `/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/bookings/${id}/confirm`, }, - - OTP: { - SEND: "/api/otp/send", - VERIFY: "/api/otp/verify", - } -}; \ No newline at end of file +}; diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts new file mode 100644 index 000000000..dd9a1c5b6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -0,0 +1,185 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import type { + LoginPayload, + LoginResponse, + SignupPayload, + SignupResponse, + OtpResponse, +} from "@/types/auth"; +import type { Result } from "@/utils/result"; +import { extractApiError } from "@/utils/result"; + +function setCookie(name: string, value: string, days: number) { + const expires = new Date(); + expires.setDate(expires.getDate() + days); + document.cookie = `${name}=${value}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`; +} + +function getCookie(name: string): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; +} + +const useAuth = () => { + const queryClient = useQueryClient(); + + const authQuery = useQuery( + api.auth.getMyInfo.queryOptions({ + enabled: !!getCookie("auth-token"), + retry: false, + staleTime: 10 * 60 * 1000, + }), + ); + + const customerQuery = useQuery( + api.customers.getByUserId.queryOptions({ + input: { id: authQuery.data?.id ?? "" }, + enabled: !!authQuery.data?.id, + retry: false, + staleTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + }), + ); + + const hasToken = !!getCookie("auth-token"); + const isPending = authQuery.isPending && hasToken; + + const login = async ( + payload: LoginPayload, + ): Promise> => { + try { + const res = await api.auth.login.call(payload); + setCookie("auth-token", res.token, 7); + setCookie("refresh-token", res.refreshToken, 7); + await authQuery.refetch(); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const signup = async ( + payload: SignupPayload, + ): Promise> => { + try { + const res = await api.auth.createUser.call(payload); + setCookie("auth-token", res.token, 7); + setCookie("refresh-token", res.refreshToken, 7); + await authQuery.refetch(); + const otpCode = res.otp?.split(" ")?.[6] ?? ""; + localStorage.setItem("otp", otpCode); + localStorage.setItem("otp-phone", payload.phoneNumber); + localStorage.setItem("otp-email", payload.email); + api.auth.sendOTP + .call({ phone: payload.phoneNumber, otp: otpCode }) + .catch(() => { }); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const setPassword = async (data: { + newPassword: string; + confirmPassword: string; + }): Promise> => { + try { + const userId = authQuery.data?.id ?? ""; + const email = localStorage.getItem("otp-email") ?? ""; + const verificationCode = localStorage.getItem("otp") ?? ""; + await api.auth.setPassword.call({ + newPassword: data.newPassword, + confirmPassword: data.confirmPassword, + userId, + email, + verificationCode, + }); + ["userId", "otp", "otp-phone", "otp-email"].forEach((k) => + localStorage.removeItem(k), + ); + await queryClient.invalidateQueries({ + queryKey: api.auth.getMyInfo.queryKey(), + }); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const verifyOTP = async (otp: string): Promise> => { + try { + const phone = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.verifyOTP.call({ phone, otp }); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const sendOTP = async (otp: string): Promise> => { + try { + const phone = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.sendOTP.call({ phone, otp }); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const generateVerificationCode = async ( + type: string, + ): Promise> => { + try { + const email = localStorage.getItem("otp-email") ?? ""; + const phoneNumber = localStorage.getItem("otp-phone") ?? ""; + const res = await api.auth.generateVerificationCode.call({ + email, + phoneNumber, + type, + }); + return { success: true, data: res }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const logout = async () => { + try { + await api.auth.logout.call(); + } catch { + // proceed with client-side cleanup even if server call fails + } + [ + "auth-token", + "refresh-token", + "auth-user", + "current-position-id", + "selected-position-id", + ].forEach((name) => { + document.cookie = `${name}=; Max-Age=0; path=/`; + }); + localStorage.clear(); + queryClient.clear(); + window.location.href = "/login"; + }; + + return { + isPending, + user: authQuery.data ?? null, + customer: customerQuery.data ?? null, + login, + signup, + setPassword, + verifyOTP, + sendOTP, + generateVerificationCode, + logout, + authQuery, + customerQuery, + }; +}; + +export default useAuth; diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 45014a8d5..8c4fbeb96 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -2,18 +2,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import "@tria-plc/iamui-common/styles.css"; import "@edr/ui-common/styles.css"; import "../index.css"; import "@edr/ui-common/theme.css"; import App from "./App"; -import { - AuthProvider, - configureIam, - UserProvider, - axiosInstance, -} from "@tria-plc/iamui-common"; // Purge cookies that were stored as the literal string "undefined" before the // envelope interceptor fix. Without this, stale sessions would keep sending @@ -29,36 +22,6 @@ import { }); const queryClient = new QueryClient(); -window.__IAM_CONFIG__ = { - apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`, - postLoginPath: "/", -}; - -// Unwrap the StandardResponse envelope ({ success, data, timestamp }) that the -// freight API's ResponseTransformInterceptor adds to every response, so that -// iamui-common can read response.data.token / response.data fields as expected. -axiosInstance.interceptors.response.use((response) => { - if ( - response.data && - typeof response.data === "object" && - "success" in response.data && - "data" in response.data - ) { - response.data = response.data.data; - } - return response; -}); -window.__USER_MANAGEMENT_BRANDING__ = { - organizationName: "EDR Platform", - appName: "EDR Portal", - moduleBasePath: "/user-management", - backToAppPath: "/", - backToAppLabel: "Back to dashboard", -}; - -configureIam({ - apiUrl: `${import.meta.env.VITE_BASE_API_URL}/api`, -}); const rootElement = document.getElementById("root"); @@ -70,11 +33,7 @@ createRoot(document.getElementById("root")!).render( - - - - - + , diff --git a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx index d0faeb662..54bffc47b 100644 --- a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx @@ -1,3 +1,4 @@ +import { Link } from "react-router-dom"; import { ArrowRight, BarChart3, @@ -85,9 +86,7 @@ export default function EDRFreightLandingPage() {
-

- EDR Freight -

+

EDR Freight

Rail Logistics Platform @@ -119,20 +118,20 @@ export default function EDRFreightLandingPage() {

- Login - + - Get Started - +
); -} \ No newline at end of file +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx new file mode 100644 index 000000000..440617f3e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -0,0 +1,360 @@ +import { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { + ArrowRight, + Building2, + CheckCircle2, + Clock, + DollarSign, + Eye, + Mail, + MapPin, + Package, + Phone, + Plus, + Receipt, + Truck, +} from "lucide-react"; + +import { + getCurrentCustomer, + getMyBookings, + getMyInvoices, + getMyShipments, +} from "@/lib/currentCustomer"; +import { formatCurrency } from "@/pages/billing/invoices.mock"; +import type { ShipmentStatus } from "@/pages/tracking/shipments.mock"; +import type { InvoiceStatus } from "@/pages/billing/invoices.mock"; +import type { BookingStatus } from "@/pages/bookings/bookings.mock"; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@edr/ui-common"; + +export default function MyPortalPage() { + const me = useMemo(() => getCurrentCustomer(), []); + const myBookings = useMemo(() => getMyBookings(), []); + const myShipments = useMemo(() => getMyShipments(), []); + const myInvoices = useMemo(() => getMyInvoices(), []); + + const activeBookings = myBookings.filter( + (b) => b.status === "Confirmed" || b.status === "In Transit", + ); + const activeShipments = myShipments.filter((s) => s.status === "In Transit"); + const outstandingInvoices = myInvoices.filter( + (inv) => inv.status === "Sent" || inv.status === "Overdue", + ); + const totalOutstanding = outstandingInvoices + .filter((inv) => inv.currency === "USD") + .reduce((sum, inv) => sum + inv.amount, 0); + const totalSpent = myInvoices + .filter((inv) => inv.status === "Paid" && inv.currency === "USD") + .reduce((sum, inv) => sum + inv.amount, 0); + + const recentBookings = [...myBookings].slice(0, 5); + const recentInvoices = [...myInvoices].slice(0, 4); + + return ( +
+
+ {/* Welcome banner */} +
+
+
+
+ {me.company.charAt(0)} +
+
+

Welcome back

+

{me.name}

+

+ + {me.company} + · + + {me.customerType} + +

+
+
+ +
+ + + + + + +
+
+
+ + {/* Active Shipments */} + + +
+ Active Shipments + + Live tracking for your in-flight cargo + +
+ + View all + + +
+ + + {activeShipments.length === 0 ? ( +

+ No shipments currently in transit. +

+ ) : ( +
+ {activeShipments.slice(0, 4).map((shipment) => ( +
+
+ + {shipment.reference} + + +
+

+ {shipment.originStation} + + {shipment.destinationStation} +

+
+ + + {shipment.currentLocation} + + ETA {shipment.eta} +
+
+
+
+
+ ))} +
+ )} + + + + {/* Recent bookings */} + + +
+ Recent Bookings + Your latest freight requests +
+ + View all + + +
+ + + {recentBookings.length === 0 ? ( +

+ You haven't booked any freight yet. +

+ ) : ( +
+ + + + + + + + + + + + {recentBookings.map((booking) => ( + + + + + + + + ))} + +
ReferenceRouteCargoStatus + Action +
+ {booking.reference} + + {booking.originStation} → {booking.destinationStation} + + {booking.cargoType} + + + + + + +
+
+ )} +
+
+ + {/* Invoices */} + + +
+ Recent Invoices + + {outstandingInvoices.length} outstanding · {myInvoices.length}{" "} + total + +
+ + View all + + +
+ + + {recentInvoices.length === 0 ? ( +

+ No invoices yet. +

+ ) : ( +
+ {recentInvoices.map((invoice) => ( +
+
+ + +
+

+ {formatCurrency(invoice.amount, invoice.currency)} +

+

+ + Due {invoice.dueDate} +

+
+ ))} +
+ )} +
+
+
+
+ ); +} + +function ProfileRow({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+
{icon}
+
+

{label}

+

{value}

+
+
+ ); +} + +function ShipmentBadge({ status }: { status: ShipmentStatus }) { + const styles: Record = { + "In Transit": "bg-indigo-100 text-indigo-700", + Delivered: "bg-emerald-100 text-emerald-700", + Delayed: "bg-red-100 text-red-700", + }; + return ( + + {status} + + ); +} + +function BookingBadge({ status }: { status: BookingStatus }) { + const styles: Record = { + Pending: "bg-amber-100 text-amber-700", + Confirmed: "bg-sky-100 text-sky-700", + "In Transit": "bg-indigo-100 text-indigo-700", + Delivered: "bg-emerald-100 text-emerald-700", + Cancelled: "bg-red-100 text-red-700", + }; + return ( + + {status} + + ); +} + +function InvoiceBadge({ status }: { status: InvoiceStatus }) { + const styles: Record = { + Draft: "bg-slate-100 text-slate-600", + Sent: "bg-sky-100 text-sky-700", + Paid: "bg-emerald-100 text-emerald-700", + Overdue: "bg-red-100 text-red-700", + Cancelled: "bg-amber-100 text-amber-700", + }; + return ( + + {status} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx new file mode 100644 index 000000000..4cfc64619 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { ArrowRight, Mail, Phone, Loader2 } from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; +import PhoneInput from "@/components/auth/PhoneInput"; + +type LoginMethod = "email" | "phone"; + +export default function LoginPage() { + const navigate = useNavigate(); + const { login } = useAuth(); + const [method, setMethod] = useState("email"); + const [identifier, setIdentifier] = useState(""); + const [countryCode, setCountryCode] = useState("+251"); + const [phoneNumber, setPhoneNumber] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const loginId = method === "email" + ? identifier + : `${countryCode}${phoneNumber.startsWith("0") ? phoneNumber.slice(1) : phoneNumber}`; + const result = await login({ email: loginId, password }); + if (result.success) { + navigate("/"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+ +
+

Welcome back

+

+ Enter your credentials to access your portal +

+
+ +
+
+ + +
+ + + {method === "email" ? ( + + Email Address + setIdentifier(e.target.value)} + required + disabled={loading} + /> + + ) : ( + ) => setCountryCode(e.target.value), + }} + phone={{ + value: phoneNumber, + onChange: (e: React.ChangeEvent) => setPhoneNumber(e.target.value), + }} + /> + )} + + +
+ Password + +
+ setPassword(e.target.value)} + required + disabled={loading} + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + + +

+ Don't have an account?{" "} + +

+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx new file mode 100644 index 000000000..cea9d1774 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -0,0 +1,520 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + ArrowRight, + ArrowLeft, + Building2, + User, + FileText, + CheckCircle2, + Loader2, +} from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import type { CreateCustomerDto } from "@/types/customers"; +import AuthLayout from "@/components/auth/AuthLayout"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; + +type OnboardingStep = "company" | "personnel" | "poa"; + +const onboardingSchema = z.object({ + companyName: z.string().min(1, "Company name is required"), + companyEmail: z.string().email("Invalid email address"), + companyPhone: z.string().min(1, "Company phone is required"), + companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyLocation: z.string().min(1, "Location is required"), + companyAddress: z.string().min(1, "Address is required"), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + vatNumber: z + .string() + .min(1, "VAT number is required") + .length(10, "VAT number must be exactly 10 digits"), + fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPhone: z.string().min(1, "Contact person phone is required"), + contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerName: z.string().min(1, "GM name is required"), + generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerPhone: z.string().min(1, "GM phone is required"), + generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + poaName: z.string().optional(), + poaPhone: z.string().optional(), + poaPhoneCountryCode: z.string().optional(), + poaAddress: z.string().optional(), + poaEmail: z.string().optional(), + poaLocation: z.string().optional(), +}); + +type FormData = z.infer; + +const stepFields: Record = { + company: [ + "companyName", + "companyEmail", + "companyPhone", + "companyPhoneCountryCode", + "companyLocation", + "companyAddress", + "tinNumber", + "vatNumber", + "fanNumber", + ], + personnel: [ + "contactPersonName", + "contactPersonPhone", + "contactPersonPhoneCountryCode", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + "generalManagerPhoneCountryCode", + ], + poa: [], +}; + +export default function OnboardingPage() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [step, setStep] = useState("company"); + + const { + register, + handleSubmit, + trigger, + formState: { errors }, + } = useForm({ + resolver: zodResolver(onboardingSchema), + defaultValues: { + companyName: "", + companyEmail: "", + companyPhone: "", + companyPhoneCountryCode: "+251", + companyLocation: "", + companyAddress: "", + tinNumber: "", + vatNumber: "", + fanNumber: "", + contactPersonName: "", + contactPersonPhone: "", + contactPersonPhoneCountryCode: "+251", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + generalManagerPhoneCountryCode: "+251", + poaName: "", + poaPhone: "", + poaPhoneCountryCode: "+251", + poaAddress: "", + poaEmail: "", + poaLocation: "", + }, + }); + + const createCustomerMutation = useMutation({ + mutationFn: (payload: CreateCustomerDto) => + api.customers.create.call(payload), + onSuccess: () => { + if (user) + queryClient.invalidateQueries({ + queryKey: api.customers.getByUserId.queryKey({ id: user.id }), + }); + }, + }); + + const nextStep = async () => { + if (step === "poa") { + handleSubmit(onSubmit)(); + return; + } + const fields = stepFields[step]; + const isValid = await trigger(fields); + if (!isValid) return; + setStep(step === "company" ? "personnel" : "poa"); + }; + + const prevStep = () => { + if (step === "personnel") setStep("company"); + else if (step === "poa") setStep("personnel"); + }; + + const onSubmit = async (data: FormData) => { + const nameParts = (user?.name?.en ?? "").split(" "); + const payload: CreateCustomerDto = { + userId: user!.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user!.email, + phone: user!.phoneNumber, + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + tinNumber: data.tinNumber, + vatNumber: data.vatNumber, + fanNumber: data.fanNumber, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + poaName: data.poaName || undefined, + poaPhone: + data.poaPhone && data.poaPhoneCountryCode + ? `${data.poaPhoneCountryCode}${data.poaPhone}` + : undefined, + poaAddress: data.poaAddress || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + }; + createCustomerMutation.mutate(payload); + }; + + return ( + +
+
+
+ } + active={step === "company"} + completed={step !== "company"} + /> + } + active={step === "personnel"} + completed={step === "poa"} + /> + } + active={step === "poa"} + completed={false} + /> +
+

+ {step === "company" && "Step 1 of 3 — Company Information"} + {step === "personnel" && "Step 2 of 3 — Personnel Details"} + {step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"} +

+
+ +
+ + {step === "company" && ( + <> + + Company Name + + + + +
+ + Company Email + + + + + +
+ +
+ + Location + + + + + + Address + + + +
+ +
+ + TIN Number (10 digits) + + + + + + VAT Number + + + +
+ + + FAN Number (16 digits) + + + + + )} + + {step === "personnel" && ( + <> +

+ Personal details are pulled from your account. Contact and + management info is collected below. +

+ +
+

+ Contact Person +

+
+ + Name + + + + + +
+
+ +
+ +
+

+ General Manager +

+
+ + Name + + + + + + Email + + + + + +
+
+ + )} + + {step === "poa" && ( + <> +

+ Power of Attorney details are optional. Skip if not applicable. +

+ + + PoA Name + + + +
+ + PoA Email + + + + +
+ +
+ + PoA Location + + + + + PoA Address + + +
+ + )} +
+ +
+ + + +
+
+ + ); +} + +function StepIcon({ + icon, + active, + completed, +}: { + icon: React.ReactNode; + active: boolean; + completed: boolean; +}) { + return ( +
+ {completed ? : icon} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx index 83a9cd78d..44ae45afa 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SetPasswordPage.tsx @@ -1,418 +1,217 @@ -import { setPassword } from "@/services/account"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; -import { - ArrowRight, - LockKeyhole, - ShieldCheck, - Train, - Eye, - EyeOff, -} from "lucide-react"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; +import { useState, useMemo } from "react"; import { useNavigate } from "react-router-dom"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; +import { ArrowRight, Check, Eye, EyeOff, LockKeyhole, Loader2, X } from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; +import { cn } from "@/lib/utils"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +const passwordRequirements = [ + { label: "At least 8 characters", test: (v: string) => v.length >= 8 }, + { label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) }, + { label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) }, + { label: "One number", test: (v: string) => /\d/.test(v) }, + { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, +] as const; const passwordSchema = z .object({ password: z .string() - .min( - 8, - "Password must be at least 8 characters" - ), - - confirmPassword: z - .string() - .min( - 8, - "Confirm password is required" - ), + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must include an uppercase letter") + .regex(/[a-z]/, "Password must include a lowercase letter") + .regex(/\d/, "Password must include a number") + .regex(/[^A-Za-z0-9]/, "Password must include a special character"), + confirmPassword: z.string().min(1, "Please confirm your password"), }) - .refine( - (data) => - data.password === - data.confirmPassword, - { - message: - "Passwords do not match", - path: ["confirmPassword"], - } - ); + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); -type FormData = z.infer< - typeof passwordSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function SetPasswordPage() { - const [ - showPassword, - setShowPassword, - ] = useState(false); - - const [ - showConfirmPassword, - setShowConfirmPassword, - ] = useState(false); + const navigate = useNavigate(); + const { setPassword } = useAuth(); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); const { register, handleSubmit, + watch, formState: { errors }, - reset, } = useForm({ - resolver: - zodResolver(passwordSchema), - - defaultValues: { - password: "", - confirmPassword: "", - }, + resolver: zodResolver(passwordSchema), + defaultValues: { password: "", confirmPassword: "" }, }); - const naviagte = useNavigate(); + const password = watch("password"); - // --------------------------------------------------------------------------- - // Mutation - // --------------------------------------------------------------------------- + const requirements = useMemo( + () => passwordRequirements.map((r) => ({ ...r, met: r.test(password || "") })), + [password], + ); - const setPasswordMutation = - useMutation({ - mutationFn: async ( - data: FormData - ) => setPassword({ - newPassword: data?.password, - confirmPassword: data?.confirmPassword, - userId: localStorage.getItem("userId"), - email: localStorage.getItem("otp-email"), - verificationCode: localStorage.getItem("otp"), - }), + const allMet = requirements.every((r) => r.met); - onSuccess: () => { - naviagte("/auth"); - reset(); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setLoading(true); try { - await setPasswordMutation.mutateAsync( - data - ); - } catch (err) { - console.error(err); + const result = await setPassword({ + newPassword: data.password, + confirmPassword: data.confirmPassword, + }); + if (result.success) { + navigate("/auth"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); } }; - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- - return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Account Security -
- -

- Set your secure - password -

- -

- Create a strong - password to secure - your EDR Freight - account and protect - railway logistics - operations and shipment - data. -

-
- - {/* Features */} -
- {[ - "Enterprise-grade security", - "Protected account access", - "Secure freight operations", - "Advanced authentication system", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Stats */} -
-
-
-

- Security Protection -

- -

- 256-bit -

-
- -
- Encrypted -
-
- -
-
-
-
-
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Card */} -
- {/* Header */} -
-
- -
- -

- Set Password -

- -

- Create a secure - password for your - EDR Freight account. -

-
- - {/* Success */} - {setPasswordMutation.isSuccess && ( -
- Password updated - successfully. -
- )} - - {/* Error */} - {setPasswordMutation.isError && ( -
- Failed to set - password. Please try - again. -
- )} - - {/* Form */} -
- {/* Password */} -
- - -
- - - -
- - {errors.password && ( -

- { - errors.password - .message - } -

- )} -
- - {/* Confirm Password */} -
- - -
- - - -
- - {errors.confirmPassword && ( -

- { - errors - .confirmPassword - .message - } -

- )} -
- - {/* Submit */} - -
-
-
+ +
+
+
+

Set Password

+

+ Create a secure password for your account. +

-
+ + {error && ( +
+ {error} +
+ )} + +
+ + + Password +
+ + +
+ +
+ + {password && ( +
    + {requirements.map((req) => ( +
  • + {req.met ? ( + + ) : ( + + )} + {req.label} +
  • + ))} +
+ )} + + + Confirm Password +
+ + +
+ +
+
+ + +
+ ); -} \ No newline at end of file +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 2b9b7f135..bfb175895 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,534 +1,186 @@ -import { userType } from "@/enums/userType"; -import { createOTP, createUser } from "@/services/account"; - -import { CreateUserPayload } from "@/types/createUser"; - -import { zodResolver } from "@hookform/resolvers/zod"; - -import { useMutation } from "@tanstack/react-query"; - -import { - ArrowRight, - ShieldCheck, - Train, - UserPlus, -} from "lucide-react"; - -import { useForm } from "react-hook-form"; - +import { useState } from "react"; import { useNavigate } from "react-router-dom"; - +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; - -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +import { ArrowRight, UserPlus, Loader2 } from "lucide-react"; +import { userType } from "@/enums/userType"; +import useAuth from "@/hooks/useAuth"; +import type { SignupPayload } from "@/types/auth"; +import AuthLayout from "@/components/auth/AuthLayout"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; const userSchema = z.object({ - email: z - .string() - .email("Invalid email address"), - - username: z - .string() - .min( - 3, - "Username must be at least 3 characters" - ), - - countryCode: z - .string() - .min( - 1, - "Country code is required" - ), - + email: z.string().email("Invalid email address"), + countryCode: z.string().min(1, "Country code is required"), phone: z .string() - .min( - 9, - "Phone number is too short" - ) - .max( - 9, - "Phone number is too long" - ), - + .min(9, "Phone number is too short") + .max(9, "Phone number is too long"), userType: z.string(), - name: z.object({ - en: z - .string() - .min(2, "Name is required"), - + en: z.string().min(2, "Name is required"), am: z.string().nullable(), }), }); -type FormData = z.infer< - typeof userSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function SignupPage() { const navigate = useNavigate(); + const { signup } = useAuth(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); const { register, handleSubmit, formState: { errors }, - reset, } = useForm({ - resolver: - zodResolver(userSchema), - + resolver: zodResolver(userSchema), defaultValues: { email: "", - username: "", countryCode: "+251", phone: "", - userType: - userType.individual, - - name: { - en: "", - am: "", - }, + userType: userType.individual, + name: { en: "", am: "" }, }, }); - // --------------------------------------------------------------------------- - // Create User Mutation - // --------------------------------------------------------------------------- - - const createUserMutation = - useMutation({ - mutationFn: ( - user: CreateUserPayload - ) => createUser(user), - - onSuccess: () => { - reset(); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setLoading(true); try { - const normalizedPhone = - data.phone.startsWith( - "0" - ) - ? data.phone.slice(1) - : data.phone; - - const fullPhoneNumber = `${data.countryCode - }${normalizedPhone}`; - - const payload: CreateUserPayload = - { + const normalizedPhone = data.phone.startsWith("0") + ? data.phone.slice(1) + : data.phone; + const payload: SignupPayload = { email: data.email, - - username: - data.username, - - phoneNumber: - fullPhoneNumber, - - userType: - data.userType, - - name: { - en: data.name.en, - am: - data.name.am || - "", - }, + username: data.email, + phoneNumber: `${data.countryCode}${normalizedPhone}`, + userType: data.userType, + name: { en: data.name.en, am: data.name.am ?? "" }, }; - - const res = - await createUserMutation.mutateAsync( - payload - ); - - if (res?.success) { - // save auth token - // document.cookie = `auth-token=${res.data?.token}; path=/`; - localStorage.setItem( - "auth-token", - `auth-token=${res.data?.token}; path=/` - ); - localStorage.setItem( - "userId",res.data?.userId - ); - localStorage.setItem( - "otp",res.data?.otp?.split(" ")?.[6] - ); - createOTP({ phone: payload.phoneNumber, otp:res.data?.otp?.split(" ")?.[6] }) - // save phone for otp page - localStorage.setItem( - "otp-phone", - payload.phoneNumber - ); - // save phone for set password page - - localStorage.setItem( - "otp-email", - payload.email - ); - // navigate otp page + const result = await signup(payload); + if (result.success) { navigate("/otp"); + } else { + setError(result.error.message); } - } catch (err) { - console.error(err); + } catch { + setError("An unexpected error occurred"); + } finally { + setLoading(false); } }; - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- - return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Smart Freight - Operations -
- -

- Create your freight - operations account -

- -

- Join EDR Freight to - manage shipments, - monitor railway - operations, track - consignments, and - streamline logistics - workflows across - Ethiopia and - Djibouti. -

-
- - {/* Features */} -
- {[ - "Real-time shipment tracking", - "Secure logistics management", - "Enterprise-grade operations", - "Multi-corridor freight monitoring", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Stats */} -
-
-
-

- Active Corridors -

- -

- 24+ -

-
- -
- Operational -
-
- -
-
-
-
-
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Form Card */} -
- {/* Header */} -
-
- -
- -

- Create Account -

- -

- Register to access - EDR Freight - services and railway - logistics operations. -

-
- - {/* Success */} - {createUserMutation.isSuccess && ( -
- Account created - successfully. -
- )} - - {/* Error */} - {createUserMutation.isError && ( -
- Failed to create - account. Please try - again. -
- )} - - {/* Form */} -
- {/* Full Name */} -
- - - - - {errors.name?.en && ( -

- { - errors.name.en - .message - } -

- )} -
- - {/* Username */} -
- - - - - {errors.username && ( -

- { - errors.username - .message - } -

- )} -
- - {/* Email */} -
- - - - - {errors.email && ( -

- { - errors.email - .message - } -

- )} -
- - {/* Phone */} -
- - -
- - - -
- - {(errors.countryCode || - errors.phone) && ( -

- {errors - .countryCode - ?.message || - errors.phone - ?.message} -

- )} -
- - {/* Submit */} - - - {/* Footer */} -

- Already have an - account? - - -

-
-
-
+ +
+
+
+

Create Account

+

+ Register to access EDR Freight services. +

-
+ + {error && ( +
+ {error} +
+ )} + +
+ + + Full Name + + + + + + Email Address + + + + + + + + + +

+ Already have an account? + +

+
+ ); -} \ No newline at end of file +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx index 091406df3..a09417c01 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/VerificationOtpPage.tsx @@ -1,70 +1,36 @@ -import { verificationCodeType } from "@/enums/verificationCodeType"; - -import { - generateVerificationCode, - verifyOTP, -} from "@/services/account"; - -import { zodResolver } from "@hookform/resolvers/zod"; - -import { useMutation } from "@tanstack/react-query"; - +import { useState } from "react"; import { useNavigate } from "react-router-dom"; - -import { - ArrowRight, - ShieldCheck, - Train, - MailCheck, - RotateCw, -} from "lucide-react"; - import { useForm } from "react-hook-form"; - +import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; - -// ----------------------------------------------------------------------------- -// Schema -// ----------------------------------------------------------------------------- +import { ArrowRight, MailCheck, RotateCw, Loader2 } from "lucide-react"; +import { verificationCodeType } from "@/enums/verificationCodeType"; +import useAuth from "@/hooks/useAuth"; +import AuthLayout from "@/components/auth/AuthLayout"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; const otpSchema = z.object({ - code: z - .string() - .regex( - /^\d{6}$/, - "OTP must be exactly 6 digits" - ), + code: z.string().regex(/^\d{6}$/, "OTP must be exactly 6 digits"), }); -type FormData = z.infer< - typeof otpSchema ->; - -// ----------------------------------------------------------------------------- -// Component -// ----------------------------------------------------------------------------- +type FormData = z.infer; export default function VerificationOtpPage() { - const navigate = - useNavigate(); + const navigate = useNavigate(); + const { verifyOTP, generateVerificationCode } = useAuth(); + const [verifying, setVerifying] = useState(false); + const [resending, setResending] = useState(false); + const [error, setError] = useState(null); + const [resentMessage, setResentMessage] = useState(null); - // --------------------------------------------------------------------------- - // Local Storage Data - // --------------------------------------------------------------------------- - - const phone = - localStorage.getItem( - "otp-phone" - ) || ""; - - const email = - localStorage.getItem( - "otp-email" - ) || ""; - - // --------------------------------------------------------------------------- - // Form - // --------------------------------------------------------------------------- + const phone = localStorage.getItem("otp-phone") || ""; const { register, @@ -72,383 +38,165 @@ export default function VerificationOtpPage() { formState: { errors }, watch, } = useForm({ - resolver: - zodResolver(otpSchema), - - defaultValues: { - code: "", - }, + resolver: zodResolver(otpSchema), + defaultValues: { code: "" }, }); - const otpValue = - watch("code"); + const otpValue = watch("code"); - // --------------------------------------------------------------------------- - // Verify Mutation - // --------------------------------------------------------------------------- - - const verifyMutation = - useMutation({ - mutationFn: async ( - data: { - phone: string; - otp: string; - } - ) => verifyOTP(data), - - onSuccess: () => { - navigate( - "/set-password" - ); - }, - }); - - // --------------------------------------------------------------------------- - // Resend Mutation - // --------------------------------------------------------------------------- - - const resendMutation = - useMutation({ - mutationFn: async () => { - return generateVerificationCode( - { - email, - phoneNumber: - phone, - - type: - verificationCodeType.setPassword, - } - ); - }, - }); - - // --------------------------------------------------------------------------- - // Submit - // --------------------------------------------------------------------------- - - const onSubmit = async ( - data: FormData - ) => { + const onSubmit = async (data: FormData) => { + setError(null); + setVerifying(true); try { - await verifyMutation.mutateAsync( - { - phone, - otp: data.code, - } - ); - } catch (err) { - console.error(err); + const result = await verifyOTP(data.code); + if (result.success) { + navigate("/set-password"); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setVerifying(false); } }; - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- + const handleResend = async () => { + setResentMessage(null); + setResending(true); + try { + const result = await generateVerificationCode(verificationCodeType.setPassword); + if (result.success) { + setResentMessage("New OTP code sent successfully."); + } else { + setError(result.error.message); + } + } catch { + setError("An unexpected error occurred"); + } finally { + setResending(false); + } + }; - const maskedPhone = - phone.length > 4 - ? `${phone.slice( - 0, - 7 - )}******` - : phone; - - // --------------------------------------------------------------------------- - // UI - // --------------------------------------------------------------------------- + const maskedPhone = phone.length > 4 ? `${phone.slice(0, 7)}******` : phone; return ( -
-
- {/* ------------------------------------------------------------------ */} - {/* Left Side */} - {/* ------------------------------------------------------------------ */} - -
-
- -
- {/* Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* Hero */} -
-
- Secure - Verification -
- -

- Verify your - account securely -

- -

- Enter the - verification code - sent to your phone - number to continue - using EDR Freight - logistics services. -

-
- - {/* Features */} -
- {[ - "Secure OTP verification", - "Protected account access", - "Fast identity confirmation", - "Enterprise-grade security", - ].map((item) => ( -
-
- -
- - - {item} - -
- ))} -
-
- - {/* Footer Stats */} -
-
-
-

- Verification - Security -

- -

- 99.9% -

-
- -
- Protected -
-
- -
-
-
-
+ +
+
+
- - {/* ------------------------------------------------------------------ */} - {/* Right Side */} - {/* ------------------------------------------------------------------ */} - -
-
- {/* Mobile Logo */} -
-
- -
- -
-

- EDR Freight -

- -

- Railway Logistics - Platform -

-
-
- - {/* OTP Card */} -
- {/* Header */} -
-
- -
- -

- OTP Verification -

- -

- Enter the - 6-digit code sent - to: -

- -
-

- {maskedPhone} -

-
-
- - {/* Success */} - {verifyMutation.isSuccess && ( -
- Verification - successful. -
- )} - - {/* Error */} - {verifyMutation.isError && ( -
- Invalid OTP - code. Please try - again. -
- )} - - {/* Resend Success */} - {resendMutation.isSuccess && ( -
- New OTP code sent - successfully. -
- )} - - {/* Form */} -
- {/* OTP */} -
- - - - -
- {errors.code ? ( -

- { - errors.code - .message - } -

- ) : ( -

- Enter the OTP - sent to your - phone -

- )} - - - { - otpValue.length - } - /6 - -
-
- - {/* Verify Button */} - - - {/* Resend */} - - - {/* Footer */} -

- Didn’t receive - the code? - - -

-
-
-
+

OTP Verification

+

Enter the 6-digit code sent to:

+
+

{maskedPhone}

-
+ + {error && ( +
+ {error} +
+ )} + + {resentMessage && ( +
+ {resentMessage} +
+ )} + +
+ + + Verification Code + +
+ {errors.code ? ( + + ) : ( +

Enter the OTP sent to your phone

+ )} + {otpValue.length}/6 +
+
+
+ + + + + +

+ Didn't receive the code? + +

+
+
); -} \ No newline at end of file +} diff --git a/apps/edr-freight-web/portal/src/pages/admin/DeleteDropdownSettingDialog.tsx b/apps/edr-freight-web/portal/src/pages/admin/DeleteDropdownSettingDialog.tsx deleted file mode 100644 index 1ca4e0d23..000000000 --- a/apps/edr-freight-web/portal/src/pages/admin/DeleteDropdownSettingDialog.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useState, type ReactNode } from "react"; - -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; - -import { Button } from "@/components/ui/button"; - -export interface DeleteDropdownSettingDialogProps { - settingLabel: string; - settingCode: string; - onConfirm?: () => void; - children?: ReactNode; - open?: boolean; - onOpenChange?: (open: boolean) => void; -} - -export default function DeleteDropdownSettingDialog({ - settingLabel, - settingCode, - onConfirm, - children, - open: openProp, - onOpenChange, -}: DeleteDropdownSettingDialogProps) { - const isControlled = openProp !== undefined; - const [internalOpen, setInternalOpen] = useState(false); - const open = isControlled ? openProp : internalOpen; - const setOpen = (next: boolean) => { - if (!isControlled) setInternalOpen(next); - onOpenChange?.(next); - }; - - return ( - - {children ? {children} : null} - - - - - Delete dropdown setting? - - - - This will remove{" "} - {settingLabel}{" "} - ({settingCode}) and all - of its options. Forms referencing this code will fall back to - empty options. - - - - - - - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/admin/DeleteFileUploadSettingDialog.tsx b/apps/edr-freight-web/portal/src/pages/admin/DeleteFileUploadSettingDialog.tsx deleted file mode 100644 index 84693fa28..000000000 --- a/apps/edr-freight-web/portal/src/pages/admin/DeleteFileUploadSettingDialog.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ReactNode } from "react"; - -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; - -import { Button } from "@/components/ui/button"; - -export interface DeleteFileUploadSettingDialogProps { - settingLabel: string; - settingCode: string; - onConfirm?: () => void; - children: ReactNode; -} - -export default function DeleteFileUploadSettingDialog({ - settingLabel, - settingCode, - onConfirm, - children, -}: DeleteFileUploadSettingDialogProps) { - return ( - - {children} - - - - - Delete file upload setting? - - - - This will remove{" "} - {settingLabel}{" "} - ({settingCode}) and all - of its fields. Forms referencing this code will fall back to no - uploads. - - - - - - - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/admin/DropdownSettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/admin/DropdownSettingsPage.tsx deleted file mode 100644 index 1b77dcc59..000000000 --- a/apps/edr-freight-web/portal/src/pages/admin/DropdownSettingsPage.tsx +++ /dev/null @@ -1,468 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { - AlertCircle, - Boxes, - CheckCircle2, - Eye, - Filter, - ListOrdered, - Loader2, - MoreHorizontal, - Pencil, - Plus, - Search, - Settings, - Shield, - Sparkles, - Trash2, -} from "lucide-react"; - -import Breadcrumbs from "@/components/Breadcrumbs"; -import EditDropdownSettingDialog from "./EditDropdownSettingDialog"; -import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog"; -import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog"; -import { - useDeleteDropdownSetting, - useDropdownSettings, -} from "@/hooks/useDropdownSettings"; -import type { DropdownSetting } from "@/types/dropdownSettings"; -import { - DataTable, - DataTableFooter, - type ColumnDef, - usePagination, - Button, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Input, - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@edr/ui-common"; - -type ActiveDialog = "edit" | "options" | "delete"; - -export default function DropdownSettingsPage() { - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [query, setQuery] = useState(""); - - const [activeDialog, setActiveDialog] = useState(null); - const [activeSetting, setActiveSetting] = useState( - null, - ); - - const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => { - // Defer past the DropdownMenu's close cycle. Radix's modal lock can leave - // `pointer-events: none` on when a menu closes and a dialog opens - // in the same frame — wait two RAFs and then explicitly reset the body - // style so the dialog interior is interactive. - requestAnimationFrame(() => { - requestAnimationFrame(() => { - document.body.style.pointerEvents = ""; - setActiveSetting(setting); - setActiveDialog(dialog); - }); - }); - }; - const closeDialog = () => { - setActiveDialog(null); - // Keep activeSetting briefly so dialog content doesn't flash empty during - // the close animation; cleared on next open. - }; - - // Belt-and-suspenders for the Radix pointer-events leak: any time the active - // dialog changes, schedule a body-style cleanup after the next paint. - useEffect(() => { - const id = requestAnimationFrame(() => { - if (document.body.style.pointerEvents === "none") { - document.body.style.pointerEvents = ""; - } - }); - return () => cancelAnimationFrame(id); - }, [activeDialog]); - - const { data, isLoading, isError, error } = useDropdownSettings(); - const deleteMutation = useDeleteDropdownSetting(); - - const dropdownSettings = useMemo( - () => (Array.isArray(data) ? data : []), - [data], - ); - - const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return dropdownSettings; - return dropdownSettings.filter( - (s) => - s.code.toLowerCase().includes(q) || - s.label.toLowerCase().includes(q) || - (s.description ?? "").toLowerCase().includes(q), - ); - }, [dropdownSettings, query]); - - const total = filtered.length; - const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); - const start = pagination.pageIndex * pagination.pageSize; - const end = Math.min(start + pagination.pageSize, total); - - const paginatedData = useMemo( - () => filtered.slice(start, end), - [start, end, filtered], - ); - - const totalOptions = dropdownSettings.reduce( - (sum, s) => sum + (s.children?.length ?? 0), - 0, - ); - const multipleCount = dropdownSettings.filter((s) => s.multiple).length; - const searchableCount = dropdownSettings.filter( - (s) => s.meta?.searchable, - ).length; - - const status: "loading" | "error" | "success" = isLoading - ? "loading" - : isError - ? "error" - : "success"; - - const columns: ColumnDef[] = [ - { - id: "setting", - header: "Setting", - cell: ({ row }) => { - const s = row.original; - return ( -
-
- -
-
-

{s.label}

-

- {s.description ?? "No description"} -

-
-
- ); - }, - }, - { - id: "code", - header: "Code", - cell: ({ row }) => ( - - {row.original.code} - - ), - }, - { - id: "options", - header: "Options", - cell: ({ row }) => { - const s = row.original; - return ( -
- - {s.children?.length ?? 0} -
- ); - }, - }, - { - id: "behavior", - header: "Behavior", - cell: ({ row }) => { - const s = row.original; - return ( -
- {s.multiple ? ( - - ) : ( - - )} - {s.meta?.searchable ? : null} - {s.meta?.clearable ? : null} -
- ); - }, - }, - { - id: "permissions", - header: "Permissions", - cell: ({ row }) => { - const s = row.original; - const perms = s.meta?.permissions ?? []; - return ( -
- {perms.length === 0 ? ( - - ) : ( - perms.map((p) => ( - - - {p} - - )) - )} -
- ); - }, - }, - { - id: "actions", - size: 40, - cell: ({ row }) => { - const setting = row.original; - return ( -
e.stopPropagation()} - > - - - - - - - - View - - openDialogFor("options", setting)} - > - - Options - - - openDialogFor("edit", setting)} - > - - Edit - - - openDialogFor("delete", setting)} - variant="destructive" - > - - Delete - - - -
- ); - }, - }, - ]; - - return ( -
-
- - - -
-

- Dropdown Settings -

-

- Manage every dynamic dropdown across the platform — labels, - options, ordering, and permissions. -

-
- -
-
- - { - setQuery(e.target.value); - setPagination({ - pageIndex: 0, - pageSize: pagination.pageSize, - }); - }} - placeholder="Search by code, label, description..." - className="pl-8!" - /> -
- - - - -
-
- -
- } - /> - } - /> - } - /> - } - /> -
- - {isError ? ( - - - - Failed to load dropdown settings.{" "} - {error instanceof Error ? error.message : "Unknown error."} - - - ) : null} - - - -
- Registered Dropdowns - - Every dynamic dropdown the platform reads from. - -
- - -
- - - {isLoading ? ( -
- - Loading dropdown settings… -
- ) : ( - { }} - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount: pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - }} - containerClassName="border-b shadow-none" - footer={DataTableFooter} - /> - )} -
-
-
- - {/* Controlled dialogs — hoisted out of the DropdownMenu so they can open - reliably after a menu item is selected. */} - {activeSetting ? ( - <> - (next ? null : closeDialog())} - /> - (next ? null : closeDialog())} - /> - deleteMutation.mutate(activeSetting.id)} - open={activeDialog === "delete"} - onOpenChange={(next) => (next ? null : closeDialog())} - /> - - ) : null} -
- ); -} - -function StatCard({ - label, - value, - icon, -}: { - label: string; - value: number; - icon: React.ReactNode; -}) { - return ( - - -
-

{label}

-

{value}

-
-
- {icon} -
-
-
- ); -} - -function BehaviorChip({ - label, - muted = false, -}: { - label: string; - muted?: boolean; -}) { - return ( - - {label} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/admin/EditDropdownSettingDialog.tsx b/apps/edr-freight-web/portal/src/pages/admin/EditDropdownSettingDialog.tsx deleted file mode 100644 index 14922446c..000000000 --- a/apps/edr-freight-web/portal/src/pages/admin/EditDropdownSettingDialog.tsx +++ /dev/null @@ -1,336 +0,0 @@ -import { useState, type ReactNode } from "react"; -import { Hash, Loader2 } from "lucide-react"; - -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; -import { Textarea } from "@/components/ui/textarea"; - -import type { - CreateDropdownSettingDto, - DropdownSetting, - UpdateDropdownSettingDto, -} from "@/types/dropdownSettings"; -import { - useCreateDropdownSetting, - useUpdateDropdownSetting, -} from "@/hooks/useDropdownSettings"; - -export interface EditDropdownSettingDialogProps { - mode?: "create" | "edit"; - setting?: DropdownSetting; - /** Optional trigger element. When omitted, the dialog renders content only and is fully controlled. */ - children?: ReactNode; - /** Controlled open state. When provided, internal state is ignored. */ - open?: boolean; - onOpenChange?: (open: boolean) => void; -} - -function parsePermissions(raw: string): string[] { - return raw - .split(",") - .map((s) => s.trim()) - .filter(Boolean); -} - -export default function EditDropdownSettingDialog({ - mode = "create", - setting, - children, - open: openProp, - onOpenChange, -}: EditDropdownSettingDialogProps) { - const isEdit = mode === "edit"; - const isControlled = openProp !== undefined; - - const [internalOpen, setInternalOpen] = useState(false); - const open = isControlled ? openProp : internalOpen; - const setOpen = (next: boolean) => { - if (!isControlled) setInternalOpen(next); - onOpenChange?.(next); - }; - const [code, setCode] = useState(setting?.code ?? ""); - const [label, setLabel] = useState(setting?.label ?? ""); - const [description, setDescription] = useState(setting?.description ?? ""); - const [icon, setIcon] = useState(setting?.meta?.icon ?? ""); - const [color, setColor] = useState(setting?.meta?.color ?? ""); - const [permissions, setPermissions] = useState( - setting?.meta?.permissions?.join(", ") ?? "", - ); - const [version, setVersion] = useState(setting?.meta?.version ?? "1.0"); - const [multiple, setMultiple] = useState(setting?.multiple ?? false); - const [searchable, setSearchable] = useState( - setting?.meta?.searchable ?? false, - ); - const [clearable, setClearable] = useState( - setting?.meta?.clearable ?? false, - ); - const [error, setError] = useState(null); - - const createMutation = useCreateDropdownSetting(); - const updateMutation = useUpdateDropdownSetting(); - const pending = createMutation.isPending || updateMutation.isPending; - - const reset = () => { - setCode(setting?.code ?? ""); - setLabel(setting?.label ?? ""); - setDescription(setting?.description ?? ""); - setIcon(setting?.meta?.icon ?? ""); - setColor(setting?.meta?.color ?? ""); - setPermissions(setting?.meta?.permissions?.join(", ") ?? ""); - setVersion(setting?.meta?.version ?? "1.0"); - setMultiple(setting?.multiple ?? false); - setSearchable(setting?.meta?.searchable ?? false); - setClearable(setting?.meta?.clearable ?? false); - setError(null); - }; - - const buildPayload = (): CreateDropdownSettingDto => ({ - code: code.trim(), - label: label.trim(), - description: description.trim() || undefined, - multiple, - meta: { - ...(icon.trim() ? { icon: icon.trim() } : {}), - ...(color.trim() ? { color: color.trim() } : {}), - searchable, - clearable, - ...(version.trim() ? { version: version.trim() } : {}), - permissions: parsePermissions(permissions), - }, - }); - - const handleSubmit = () => { - setError(null); - if (!code.trim() || !label.trim()) { - setError("Code and label are required."); - return; - } - if (!/^[a-z][a-z0-9_]*$/i.test(code.trim())) { - setError( - "Code must start with a letter and contain only letters, digits, or underscores.", - ); - return; - } - - const payload = buildPayload(); - - const onDone = () => { - setOpen(false); - if (!isEdit) reset(); - }; - const onError = (err: unknown) => { - setError( - err instanceof Error - ? err.message - : "Something went wrong. Try again.", - ); - }; - - if (isEdit && setting) { - // Update DTO omits `code` (immutable); strip it before sending. - const { code: _unused, ...updateDto } = payload; - void _unused; - updateMutation.mutate( - { id: setting.id, dto: updateDto as UpdateDropdownSettingDto }, - { onSuccess: onDone, onError }, - ); - } else { - createMutation.mutate(payload, { onSuccess: onDone, onError }); - } - }; - - return ( - { - setOpen(next); - if (!next) reset(); - }} - > - {children ? {children} : null} - - - - - {isEdit ? "Edit Dropdown Setting" : "New Dropdown Setting"} - - - {isEdit - ? "Update the metadata for this dropdown setting." - : "Define a new dynamic dropdown that admins can manage."} - - - -
-
- -
- - setCode(e.target.value)} - placeholder="e.g. cargo_type" - className="pl-10 font-mono" - disabled={isEdit} - /> -
-

- {isEdit - ? "Code is immutable after creation." - : "Stable identifier used in code. Use snake_case."} -

-
- -
- - setLabel(e.target.value)} - placeholder="e.g. Cargo Type" - /> -
- -
- -