From 82c6b1284e145c05d926b19c5be60db4e540a329 Mon Sep 17 00:00:00 2001 From: marshal Date: Sat, 23 May 2026 09:31:52 +0300 Subject: [PATCH 1/2] implement contract booking functionality and MinIO setup --- apps/edr-freight-api/package.json | 2 + .../modules/bookings/bookings.controller.ts | 124 ++++- .../src/modules/bookings/bookings.module.ts | 3 +- .../modules/bookings/bookings.repository.ts | 43 +- .../src/modules/bookings/bookings.service.ts | 515 +++++++++++++++++- .../bookings/dto/create-booking.dto.ts | 180 +++++- .../bookings/dto/filter-booking.dto.ts | 75 ++- .../bookings/dto/update-booking.dto.ts | 5 + .../modules/bookings/dto/update-status.dto.ts | 42 ++ .../bookings/entities/booking.entity.ts | 136 ++++- .../src/modules/minio/index.ts | 2 + .../src/modules/minio/minio.config.ts | 10 + .../src/modules/minio/minio.module.ts | 11 + .../src/modules/minio/minio.service.ts | 65 +++ apps/edr-freight-api/test-minio-upload.js | 45 ++ pnpm-lock.yaml | 13 + 16 files changed, 1231 insertions(+), 40 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts create mode 100644 apps/edr-freight-api/src/modules/minio/index.ts create mode 100644 apps/edr-freight-api/src/modules/minio/minio.config.ts create mode 100644 apps/edr-freight-api/src/modules/minio/minio.module.ts create mode 100644 apps/edr-freight-api/src/modules/minio/minio.service.ts create mode 100644 apps/edr-freight-api/test-minio-upload.js diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 813914bca..20ee4aeab 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -29,6 +29,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^17.4.2", + "minio": "7.1.3", "pg": "^8.13.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -45,6 +46,7 @@ "@types/amqplib": "^0.10.8", "@types/express": "^5.0.0", "@types/jest": "^29.5.13", + "@types/multer": "^2.1.0", "@types/node": "^20.14.0", "@types/supertest": "^6.0.2", "jest": "^29.7.0", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index d6fd41220..98f8d3627 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -6,42 +6,154 @@ import { HttpCode, Param, ParseUUIDPipe, + Patch, Post, Query, + UploadedFiles, + UseInterceptors, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; +import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger"; import { BookingsService } from "./bookings.service"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; +import { UpdateStatusDto } from "./dto/update-status.dto"; @ApiTags("bookings") @Controller("bookings") export class BookingsController { constructor(private readonly bookingsService: BookingsService) {} + // ── 1. Create booking (multipart/form-data) ────────────────────────── @Post() - @ApiOperation({ summary: "Create a new freight booking" }) - create(@Body() dto: CreateBookingDto) { - return this.bookingsService.create(dto); + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Create a new freight booking", + description: + "Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " + + "Auto-enables consolidation when containerType=20FT and odd quantity.", + }) + @ApiBody({ + description: + "Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " + + "File metadata is stored in the documents JSONB column.", + type: CreateBookingDto, + }) + create( + @Body() dto: CreateBookingDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + console.log('[BookingsController] Files received:', files?.length, files?.map(f => ({ fieldname: f.fieldname, originalname: f.originalname, size: f.size, mimetype: f.mimetype }))); + return this.bookingsService.create(dto, files ?? []); } + // ── 2. Update draft booking (multipart/form-data) ───────────────────── + @Patch(":id") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: "Update a draft booking", + description: "Only DRAFT bookings can be updated. New files are merged into existing documents.", + }) + @ApiBody({ type: UpdateBookingDto }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateBookingDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.bookingsService.update(id, dto, files ?? []); + } + + // ── 3. List bookings (paginated + filtered) ─────────────────────────── @Get() - @ApiOperation({ summary: "List freight bookings (paginated)" }) + @ApiOperation({ + summary: "List freight bookings (paginated)", + description: + "Filter by status, customerId, contractType, serviceType, tradeDirection, " + + "paymentCurrency, freightType, containerType, allowConsolidation, consolidationPaired. " + + "Sort by createdAt or priorityScore.", + }) findAll(@Query() filter: FilterBookingDto) { return this.bookingsService.findAll(filter); } + // ── 5. Lookup by reference (must be before :id to avoid conflict) ───── + @Get("by-reference/:reference") + @ApiOperation({ + summary: "Get a freight booking by reference number", + description: "Lookup booking by its human-readable reference string.", + }) + findByReference(@Param("reference") reference: string) { + return this.bookingsService.findByReference(reference); + } + + // ── 4. Get single booking by ID ─────────────────────────────────────── @Get(":id") @ApiOperation({ summary: "Get a freight booking by ID" }) findOne(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.findById(id); } + // ── 6. Soft-delete (DRAFT only) ─────────────────────────────────────── @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: "Soft-delete a freight booking" }) + @ApiOperation({ + summary: "Soft-delete a freight booking", + description: "Only DRAFT bookings can be deleted.", + }) remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } + + // ── 7. Unified status transition ────────────────────────────────────── + @Patch(":id/status") + @ApiOperation({ + summary: "Transition booking status", + description: + "Unified endpoint for all status transitions. Actions: " + + "SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " + + "Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " + + "Bulk/high-volume → DIRECTOR → CEO → SIGNED.", + }) + updateStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateStatusDto, + ) { + return this.bookingsService.updateStatus(id, dto); + } + + // ── 8. Request or auto-pair consolidation ───────────────────────────── + @Post(":id/consolidation") + @ApiOperation({ + summary: "Request freight consolidation", + description: + "Searches for a compatible 20FT partner (same origin, destination, tradeDirection). " + + "If a partner is found, both bookings are paired. If not, the booking enters the consolidation queue.", + }) + requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingsService.requestConsolidation(id); + } + + // ── 9. Remove consolidation pairing ─────────────────────────────────── + @Delete(":id/consolidation") + @ApiOperation({ + summary: "Remove consolidation pairing", + description: "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.", + }) + removeConsolidation(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingsService.removeConsolidation(id); + } + + // ── 10. Get consolidation details ───────────────────────────────────── + @Get(":id/consolidation") + @ApiOperation({ + summary: "Get consolidation details", + description: "Returns partner booking details and split billing information.", + }) + getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) { + return this.bookingsService.getConsolidationDetails(id); + } } 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 98bf64196..eb6d6d60f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,13 +1,14 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { MinioModule } from "../minio/minio.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])], + imports: [TypeOrmModule.forFeature([Booking]), MinioModule], controllers: [BookingsController], providers: [BookingsService, BookingsRepository], exports: [BookingsService], diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 5a3838c02..86428d44c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from "@edr/api-common"; import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { In, IsNull, Not, Repository } from "typeorm"; import { Booking } from "./entities/booking.entity"; @@ -18,4 +18,45 @@ export class BookingsRepository extends BaseRepository { findByReference(reference: string): Promise { return this.repository.findOne({ where: { reference } }); } + + /** Find a compatible consolidation partner for the given booking. */ + async findConsolidationPartner(booking: Booking): Promise { + return this.repository.findOne({ + where: { + allowConsolidation: true, + containerType: "20FT", + originStation: booking.originStation, + destinationStation: booking.destinationStation, + tradeDirection: booking.tradeDirection, + consolidationPartnerId: IsNull(), + status: In(["DRAFT", "PENDING_CONSOLIDATION"]), + id: Not(booking.id), + }, + order: { createdAt: "ASC" }, + }); + } + + /** Pair two bookings for consolidation. */ + async pairConsolidation(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: partnerId, + status: "CONSOLIDATED", + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: bookingId, + status: "CONSOLIDATED", + } as never); + } + + /** Un-pair a consolidation. Returns both booking IDs. */ + async unpairConsolidation(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + status: "PENDING_CONSOLIDATION", + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: null, + status: "PENDING_CONSOLIDATION", + } as never); + } } 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 d535ed396..138902662 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1,20 +1,219 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { IsNull, Not } from "typeorm"; +import { MinioService } from "../minio/minio.service"; import { BookingsRepository } from "./bookings.repository"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; +import { UpdateBookingDto } from "./dto/update-booking.dto"; +import { UpdateStatusDto } from "./dto/update-status.dto"; import { Booking } from "./entities/booking.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(private readonly bookingsRepository: BookingsRepository) {} + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly minioService: MinioService, + ) {} + + // ── helpers ────────────────────────────────────────────────────────── + + /** Resolve auto-consolidation flag. */ + private resolveConsolidation( + containerType: string, + containerQuantity: number, + explicit?: boolean, + ): boolean { + if (explicit === false) return false; + if (containerType === "20FT" && containerQuantity % 2 !== 0) return true; + 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( + containerType: string, + containerQuantity: number, + ): number { + if (containerType === "40FT") return containerQuantity; + return Math.ceil(containerQuantity / 2); + } + + /** Check per-container weight limit and return a warning if exceeded. */ + private checkOverweight( + containerType: string, + vgmPerUnit: number, + tradeDirection: string, + ): string | null { + if (containerType === "40FT" && vgmPerUnit > WEIGHT_LIMITS.ANY_40FT) { + return `40FT container VGM ${vgmPerUnit}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`; + } + if (containerType === "20FT") { + const limit = + tradeDirection === "IMPORT" + ? WEIGHT_LIMITS.IMPORT_20FT + : WEIGHT_LIMITS.EXPORT_20FT; + if (vgmPerUnit > limit) { + return `20FT ${tradeDirection} container VGM ${vgmPerUnit}t exceeds limit of ${limit}t`; + } + } + return null; + } + + /** Build file metadata from uploaded files and upload to MinIO. */ + private async buildDocuments( + bookingId: string, + files: Express.Multer.File[], + ): Promise> { + console.log('[BookingsService] buildDocuments called with bookingId:', bookingId, 'files count:', files.length); + const docs: Record = {}; + for (const file of files) { + console.log('[BookingsService] Processing file:', file.fieldname, file.originalname, 'size:', file.size); + const objectName = `bookings/${bookingId}/${Date.now()}_${file.originalname}`; + console.log('[BookingsService] Uploading to MinIO:', objectName); + const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype); + console.log('[BookingsService] Upload successful, URL:', url); + docs[file.fieldname] = { + originalName: file.originalname, + size: file.size, + mimeType: file.mimetype, + url, + }; + } + console.log('[BookingsService] All files processed, docs:', docs); + return docs; + } + + // ── CRUD ───────────────────────────────────────────────────────────── /** Create a new freight booking. */ - async create(dto: CreateBookingDto): Promise { - return this.bookingsRepository.create({ + async create( + dto: CreateBookingDto, + files: Express.Multer.File[], + ): Promise<{ booking: Booking; warnings: string[] }> { + const warnings: string[] = []; + + const allowConsolidation = this.resolveConsolidation( + dto.containerType, + dto.containerQuantity, + dto.allowConsolidation, + ); + + const priorityScore = this.calculatePriorityScore( + dto.paymentCurrency, + dto.serviceType, + ); + + const overweightWarning = this.checkOverweight( + dto.containerType, + dto.containerVgmPerUnit, + dto.tradeDirection, + ); + if (overweightWarning) warnings.push(overweightWarning); + + const wagonCount = this.calculateWagonCount( + dto.containerType, + dto.containerQuantity, + ); + warnings.push(`Estimated wagons required: ${wagonCount}`); + + const booking = await this.bookingsRepository.create({ ...dto, scheduledDate: new Date(dto.scheduledDate), + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + status: "DRAFT", + allowConsolidation, + priorityScore, + documents: null, }); + + // Upload files to MinIO and update booking with documents + if (files.length > 0) { + try { + const documents = await this.buildDocuments(booking.id, files); + await this.bookingsRepository.update(booking.id, { documents }); + booking.documents = documents; + } catch (err) { + console.error('[BookingsService] File upload failed, booking still created:', err); + warnings.push('File upload failed — booking was created without attached files.'); + } + } + + return { booking, warnings }; + } + + /** Update a draft booking. */ + async update( + id: string, + dto: UpdateBookingDto, + files: Express.Multer.File[], + ): Promise<{ booking: Booking; warnings: string[] }> { + const existing = await this.findById(id); + if (existing.status !== "DRAFT") { + throw new BadRequestException("Only DRAFT bookings can be updated"); + } + + const warnings: string[] = []; + const updates: Record = { ...dto }; + + if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + if (dto.startDate) updates.startDate = new Date(dto.startDate); + if (dto.endDate) updates.endDate = new Date(dto.endDate); + + // Re-evaluate consolidation if container fields changed + const containerType = dto.containerType ?? existing.containerType; + const containerQuantity = dto.containerQuantity ?? existing.containerQuantity; + updates.allowConsolidation = this.resolveConsolidation( + containerType, + containerQuantity, + 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 vgm = dto.containerVgmPerUnit ?? existing.containerVgmPerUnit; + const direction = dto.tradeDirection ?? existing.tradeDirection; + const ow = this.checkOverweight(containerType, vgm, direction); + if (ow) warnings.push(ow); + + // Merge documents - upload new files to MinIO + if (files.length > 0) { + const newDocs = await this.buildDocuments(id, files); + updates.documents = { ...(existing.documents ?? {}), ...newDocs }; + } + + const booking = await this.bookingsRepository.update(id, updates); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + + return { booking, warnings }; } /** Return a paginated list of bookings matching the filter. */ @@ -23,14 +222,31 @@ export class BookingsService { ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; + + const where: Record = {}; + if (filter.status) where.status = filter.status; + if (filter.customerId) where.customerId = filter.customerId; + if (filter.contractType) where.contractType = filter.contractType; + if (filter.serviceType) where.serviceType = filter.serviceType; + if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; + if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; + if (filter.freightType) where.freightType = filter.freightType; + if (filter.containerType) where.containerType = filter.containerType; + if (filter.allowConsolidation !== undefined) + where.allowConsolidation = filter.allowConsolidation; + if (filter.consolidationPaired === "true") + where.consolidationPartnerId = Not(IsNull()); + else if (filter.consolidationPaired === "false") + where.consolidationPartnerId = IsNull(); + + const sortField = filter.sortBy ?? "createdAt"; + const sortDir = filter.sortOrder ?? "DESC"; + const [items, total] = await this.bookingsRepository.findAndCount({ - where: { - ...(filter.status ? { status: filter.status } : {}), - ...(filter.customerId ? { customerId: filter.customerId } : {}), - }, + where, skip: (page - 1) * pageSize, take: pageSize, - order: { createdAt: "DESC" }, + order: { [sortField]: sortDir }, }); return { items, total }; } @@ -44,9 +260,286 @@ export class BookingsService { return booking; } - /** Soft-delete a booking. */ + /** Find booking by reference. */ + async findByReference(reference: string): Promise { + const booking = await this.bookingsRepository.findByReference(reference); + if (!booking) { + throw new NotFoundException(`Booking with reference "${reference}" not found`); + } + return booking; + } + + /** Soft-delete a booking (DRAFT only). */ async remove(id: string): Promise { - await this.findById(id); + const booking = await this.findById(id); + if (booking.status !== "DRAFT") { + throw new BadRequestException("Only DRAFT bookings can be deleted"); + } await this.bookingsRepository.softDelete(id); } + + // ── status workflow ────────────────────────────────────────────────── + + /** Unified status transition handler. */ + async updateStatus(id: string, dto: UpdateStatusDto): Promise { + const booking = await this.findById(id); + const { action, actorId, reason } = dto; + + switch (action) { + case "SUBMIT": + return this.handleSubmit(booking); + case "APPROVE_STAFF": + return this.handleApproveStaff(booking, actorId); + case "APPROVE_DIRECTOR": + return this.handleApproveDirector(booking, actorId); + case "APPROVE_CEO": + return this.handleApproveCeo(booking, actorId); + case "REJECT": + return this.handleReject(booking, actorId, reason); + case "CANCEL": + return this.handleCancel(booking, actorId, reason); + case "ACTIVATE": + return this.handleActivate(booking); + case "EXPIRE": + return this.handleExpire(booking); + default: + throw new BadRequestException(`Unknown action: ${action}`); + } + } + + /** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (bulk). */ + 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 updated = await this.bookingsRepository.update(booking.id, { + status: nextStatus, + } as never); + return updated!; + } + + /** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */ + private async handleApproveStaff( + booking: Booking, + actorId?: string, + ): Promise { + this.assertStatus(booking, ["PENDING_LINE_STAFF"]); + 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 + ) { + throw new BadRequestException( + "Line staff cannot approve bulk or high-volume bookings", + ); + } + + const updated = await this.bookingsRepository.update(booking.id, { + status: "APPROVED_PENDING_SIGNATURE", + approvedByStaffId: actorId, + approvedByStaffAt: new Date(), + } as never); + return updated!; + } + + /** APPROVE_DIRECTOR: APPROVED_PENDING_SIGNATURE|PENDING_DIRECTOR → SIGNED or PENDING_CEO. */ + private async handleApproveDirector( + booking: Booking, + actorId?: string, + ): Promise { + this.assertStatus(booking, [ + "APPROVED_PENDING_SIGNATURE", + "PENDING_DIRECTOR", + ]); + 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 updated = await this.bookingsRepository.update(booking.id, { + status: nextStatus, + signedByDirectorId: actorId, + signedByDirectorAt: new Date(), + } as never); + return updated!; + } + + /** APPROVE_CEO: PENDING_CEO → SIGNED. */ + private async handleApproveCeo( + booking: Booking, + actorId?: string, + ): Promise { + this.assertStatus(booking, ["PENDING_CEO"]); + if (!actorId) + throw new BadRequestException("actorId is required for APPROVE_CEO"); + + const updated = await this.bookingsRepository.update(booking.id, { + status: "SIGNED", + signedByCeoId: actorId, + signedByCeoAt: new Date(), + } as never); + return updated!; + } + + /** REJECT: PENDING_* → CANCELLED. */ + private async handleReject( + booking: Booking, + actorId?: string, + reason?: string, + ): Promise { + this.assertStatus(booking, [ + "PENDING_LINE_STAFF", + "PENDING_DIRECTOR", + "PENDING_CEO", + "APPROVED_PENDING_SIGNATURE", + ]); + if (!actorId || !reason) + throw new BadRequestException("actorId and reason are required for REJECT"); + + const updated = await this.bookingsRepository.update(booking.id, { + status: "CANCELLED", + } as never); + return updated!; + } + + /** CANCEL: DRAFT|PENDING_* → CANCELLED. */ + private async handleCancel( + booking: Booking, + _actorId?: string, + reason?: string, + ): Promise { + this.assertStatus(booking, [ + "DRAFT", + "PENDING_LINE_STAFF", + "PENDING_DIRECTOR", + "PENDING_CEO", + "APPROVED_PENDING_SIGNATURE", + ]); + if (!reason) + throw new BadRequestException("reason is required for CANCEL"); + + const updated = await this.bookingsRepository.update(booking.id, { + status: "CANCELLED", + } as never); + return updated!; + } + + /** ACTIVATE: SIGNED → ACTIVE. */ + private async handleActivate(booking: Booking): Promise { + this.assertStatus(booking, ["SIGNED"]); + const updated = await this.bookingsRepository.update(booking.id, { + status: "ACTIVE", + startDate: booking.startDate ?? new Date(), + } as never); + return updated!; + } + + /** EXPIRE: ACTIVE → EXPIRED. */ + private async handleExpire(booking: Booking): Promise { + this.assertStatus(booking, ["ACTIVE"]); + const updated = await this.bookingsRepository.update(booking.id, { + status: "EXPIRED", + endDate: new Date(), + } as never); + return updated!; + } + + /** Guard: ensure current status is one of the allowed values. */ + private assertStatus(booking: Booking, allowed: string[]): void { + if (!allowed.includes(booking.status)) { + throw new ConflictException( + `Cannot perform this action on a booking with status "${booking.status}". Allowed: ${allowed.join(", ")}`, + ); + } + } + + // ── consolidation ──────────────────────────────────────────────────── + + /** Request consolidation — auto-pair if a partner exists, else queue. */ + async requestConsolidation(id: string): Promise<{ + booking: Booking; + partner: Booking | null; + paired: boolean; + }> { + const booking = await this.findById(id); + + if (!booking.allowConsolidation) { + throw new BadRequestException("Booking is not eligible for consolidation"); + } + if (booking.containerType !== "20FT") { + throw new BadRequestException("Only 20FT containers can be consolidated"); + } + if (booking.containerQuantity % 2 === 0) { + throw new BadRequestException( + "Only odd-quantity 20FT bookings need consolidation", + ); + } + if (booking.consolidationPartnerId) { + throw new ConflictException("Booking is already paired for consolidation"); + } + + const partner = + await this.bookingsRepository.findConsolidationPartner(booking); + + if (partner) { + await this.bookingsRepository.pairConsolidation(booking.id, partner.id); + const updated = await this.findById(id); + const updatedPartner = await this.findById(partner.id); + return { booking: updated, partner: updatedPartner, paired: true }; + } + + // No partner found — enter queue + await this.bookingsRepository.update(booking.id, { + status: "PENDING_CONSOLIDATION", + } as never); + const updated = await this.findById(id); + return { booking: updated, partner: null, paired: false }; + } + + /** Remove consolidation pairing. */ + async removeConsolidation(id: string): Promise<{ + booking: Booking; + partner: Booking; + }> { + const booking = await this.findById(id); + if (!booking.consolidationPartnerId) { + throw new BadRequestException("Booking has no consolidation partner"); + } + + const partnerId = booking.consolidationPartnerId; + await this.bookingsRepository.unpairConsolidation(id, partnerId); + + const updated = await this.findById(id); + const updatedPartner = await this.findById(partnerId); + return { booking: updated, partner: updatedPartner }; + } + + /** Get consolidation details for a booking. */ + async getConsolidationDetails(id: string): Promise<{ + booking: Booking; + partner: Booking | null; + splitBilling: { bookingShare: number; partnerShare: number } | null; + }> { + const booking = await this.findById(id); + + if (!booking.consolidationPartnerId) { + return { booking, partner: null, splitBilling: null }; + } + + const partner = await this.findById(booking.consolidationPartnerId); + const splitBilling = { + bookingShare: booking.totalAmount, + partnerShare: partner.totalAmount, + }; + + return { booking, partner, splitBilling }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index c96e4fd62..f862dd0b2 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -1,7 +1,10 @@ -import { Freight } from "@edr/types"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; import { + IsBoolean, IsDateString, - IsEnum, + IsIn, + IsInt, IsNumber, IsOptional, IsString, @@ -9,25 +12,194 @@ import { Min, } from "class-validator"; +const BOOKING_STATUSES = [ + "DRAFT", + "PENDING_LINE_STAFF", + "PENDING_DIRECTOR", + "PENDING_CEO", + "APPROVED_PENDING_SIGNATURE", + "SIGNED", + "ACTIVE", + "EXPIRED", + "CANCELLED", + "PENDING_CONSOLIDATION", + "CONSOLIDATED", +] as const; + +const CONTRACT_TYPES = ["NEW", "RENEWAL"] as const; +const SERVICE_TYPES = ["RAIL_ONLY", "RAIL_AND_FORWARDING"] as const; +const EQUIPMENT_RETURNS = ["WITH_RETURN", "WITHOUT_RETURN"] as const; +const FREIGHT_TYPES = ["BULK", "BREAK_BULK"] as const; +const TRADE_DIRECTIONS = ["IMPORT", "EXPORT"] as const; +const PAYMENT_CURRENCIES = ["ETB", "USD"] as const; +const PAYMENT_STATUSES = ["PENDING", "PAID", "OVERDUE", "CANCELLED", "REFUNDED"] as const; +const CONTAINER_TYPES = ["20FT", "40FT"] as const; + +export { + BOOKING_STATUSES, + CONTRACT_TYPES, + SERVICE_TYPES, + EQUIPMENT_RETURNS, + FREIGHT_TYPES, + TRADE_DIRECTIONS, + PAYMENT_CURRENCIES, + PAYMENT_STATUSES, + CONTAINER_TYPES, +}; + export class CreateBookingDto { + // ── core ───────────────────────────────────────────────────────────── + @ApiProperty({ description: "Unique booking reference" }) @IsString() + @Transform(({ value }) => (typeof value === "string" ? value.trim() : value)) reference!: string; + @ApiProperty({ format: "uuid" }) @IsUUID() customerId!: string; + @ApiPropertyOptional({ format: "uuid" }) @IsOptional() @IsUUID() trainId?: string; + @ApiProperty({ example: "2026-06-15T00:00:00.000Z" }) @IsDateString() scheduledDate!: string; + @ApiProperty({ minimum: 0 }) @IsNumber() @Min(0) + @Transform(({ value }) => Number(value)) totalAmount!: number; + @ApiPropertyOptional({ enum: PAYMENT_STATUSES, default: "PENDING" }) @IsOptional() - @IsEnum(Freight.BookingStatus) - status?: Freight.BookingStatus; + @IsIn([...PAYMENT_STATUSES]) + paymentStatus?: string; + + // ── contract ───────────────────────────────────────────────────────── + @ApiProperty({ enum: CONTRACT_TYPES }) + @IsIn([...CONTRACT_TYPES]) + contractType!: string; + + @ApiPropertyOptional({ format: "uuid", description: "For RENEWAL — previous contract/booking ID" }) + @IsOptional() + @IsUUID() + @Transform(({ value }) => (value === "" || value == null ? undefined : value)) + previousContractId?: string; + + @ApiProperty({ enum: SERVICE_TYPES }) + @IsIn([...SERVICE_TYPES]) + serviceType!: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + firstMileEnabled?: boolean; + + @ApiPropertyOptional({ description: "Required when firstMileEnabled is true" }) + @IsOptional() + @IsString() + firstMilePickupAddress?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + lastMileEnabled?: boolean; + + @ApiPropertyOptional({ description: "Required when lastMileEnabled is true" }) + @IsOptional() + @IsString() + lastMileDeliveryAddress?: string; + + @ApiProperty({ enum: EQUIPMENT_RETURNS }) + @IsIn([...EQUIPMENT_RETURNS]) + equipmentReturn!: string; + + @ApiProperty({ description: "Origin station name or code" }) + @IsString() + originStation!: string; + + @ApiProperty({ description: "Destination station name or code" }) + @IsString() + destinationStation!: string; + + @ApiProperty({ description: "Total cargo weight in VGM tons", minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + cargoTotalWeightVgm!: number; + + @ApiProperty({ enum: FREIGHT_TYPES }) + @IsIn([...FREIGHT_TYPES]) + freightType!: string; + + @ApiPropertyOptional({ description: "Coffee, Beans, Machinery, Ro-Ro, etc." }) + @IsOptional() + @IsString() + freightSubtype?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + isHazardous?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + isRefrigerated?: boolean; + + @ApiProperty({ enum: TRADE_DIRECTIONS }) + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection!: string; + + @ApiProperty({ enum: PAYMENT_CURRENCIES }) + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency!: string; + + @ApiPropertyOptional({ example: "2026-06-15" }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ example: "2027-06-15" }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + financialTerms?: string; + + // ── container ──────────────────────────────────────────────────────── + @ApiProperty({ enum: CONTAINER_TYPES }) + @IsIn([...CONTAINER_TYPES]) + containerType!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Transform(({ value }) => Number(value)) + containerQuantity!: number; + + @ApiProperty({ description: "VGM per container in tons", minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + containerVgmPerUnit!: number; + + @ApiPropertyOptional({ + default: false, + description: "Auto-set to true when containerType=20FT and odd quantity. User may override.", + }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + allowConsolidation?: boolean; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index e4064b1e4..cc096d40c 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -1,22 +1,87 @@ -import { Freight } from "@edr/types"; -import { Type } from "class-transformer"; -import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator"; + +import { + BOOKING_STATUSES, + CONTRACT_TYPES, + CONTAINER_TYPES, + FREIGHT_TYPES, + PAYMENT_CURRENCIES, + SERVICE_TYPES, + TRADE_DIRECTIONS, +} from "./create-booking.dto"; export class FilterBookingDto { + @ApiPropertyOptional({ enum: BOOKING_STATUSES }) @IsOptional() - @IsEnum(Freight.BookingStatus) - status?: Freight.BookingStatus; + @IsIn([...BOOKING_STATUSES]) + status?: string; + @ApiPropertyOptional({ format: "uuid" }) @IsOptional() @IsUUID() customerId?: string; + @ApiPropertyOptional({ enum: CONTRACT_TYPES }) + @IsOptional() + @IsIn([...CONTRACT_TYPES]) + contractType?: string; + + @ApiPropertyOptional({ enum: SERVICE_TYPES }) + @IsOptional() + @IsIn([...SERVICE_TYPES]) + serviceType?: string; + + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) + @IsOptional() + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection?: string; + + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) + @IsOptional() + @IsIn([...FREIGHT_TYPES]) + freightType?: string; + + @ApiPropertyOptional({ enum: CONTAINER_TYPES }) + @IsOptional() + @IsIn([...CONTAINER_TYPES]) + containerType?: string; + + @ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === "true" || value === true) + allowConsolidation?: boolean; + + @ApiPropertyOptional({ description: "Filter by consolidation partner presence (true = paired, false = unpaired)" }) + @IsOptional() + @IsString() + consolidationPaired?: string; + + @ApiPropertyOptional({ enum: ["createdAt", "priorityScore"], default: "createdAt" }) + @IsOptional() + @IsIn(["createdAt", "priorityScore"]) + sortBy?: string; + + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) + @IsOptional() + @IsIn(["ASC", "DESC"]) + sortOrder?: "ASC" | "DESC"; + + @ApiPropertyOptional({ default: 1, minimum: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; + @ApiPropertyOptional({ default: 20, minimum: 1 }) @IsOptional() @Type(() => Number) @IsInt() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts new file mode 100644 index 000000000..b97a624fb --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateBookingDto } from "./create-booking.dto"; + +export class UpdateBookingDto extends PartialType(CreateBookingDto) {} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts new file mode 100644 index 000000000..9cd796840 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString, IsUUID } from "class-validator"; + +const STATUS_ACTIONS = [ + "SUBMIT", + "APPROVE_STAFF", + "APPROVE_DIRECTOR", + "APPROVE_CEO", + "REJECT", + "CANCEL", + "ACTIVATE", + "EXPIRE", +] as const; + +export { STATUS_ACTIONS }; + +export class UpdateStatusDto { + @ApiProperty({ + enum: STATUS_ACTIONS, + description: + "SUBMIT — send to approval queue | " + + "APPROVE_STAFF — line-staff approval | " + + "APPROVE_DIRECTOR — director approval / signature | " + + "APPROVE_CEO — CEO final signature | " + + "REJECT — reject at any pending stage | " + + "CANCEL — customer / admin cancellation | " + + "ACTIVATE — activate a signed booking | " + + "EXPIRE — mark an active booking as expired", + }) + @IsIn([...STATUS_ACTIONS]) + action!: string; + + @ApiPropertyOptional({ format: "uuid", description: "Actor performing the action (staff/director/CEO)" }) + @IsOptional() + @IsUUID() + actorId?: string; + + @ApiPropertyOptional({ description: "Required for REJECT and CANCEL actions" }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 0d848c516..54f678d6a 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -1,9 +1,9 @@ import { BaseEntity } from "@edr/api-common"; -import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; @Entity({ name: "bookings" }) export class Booking extends BaseEntity { + // ── core ─────────────────────────────────────────────────────────────── @Column({ name: "reference", type: "varchar", length: 64, unique: true }) reference!: string; @@ -13,13 +13,8 @@ export class Booking extends BaseEntity { @Column({ name: "train_id", type: "uuid", nullable: true }) trainId?: string | null; - @Column({ - name: "status", - type: "enum", - enum: Freight.BookingStatus, - default: Freight.BookingStatus.Draft, - }) - status!: Freight.BookingStatus; + @Column({ name: "status", type: "varchar", length: 40, default: "DRAFT" }) + status!: string; @Column({ name: "scheduled_date", type: "timestamptz" }) scheduledDate!: Date; @@ -35,9 +30,126 @@ export class Booking extends BaseEntity { @Column({ name: "payment_status", - type: "enum", - enum: Freight.PaymentStatus, - default: Freight.PaymentStatus.Pending, + type: "varchar", + length: 20, + default: "PENDING", }) - paymentStatus!: Freight.PaymentStatus; + paymentStatus!: string; + + // ── contract ─────────────────────────────────────────────────────────── + @Column({ name: "contract_type", type: "varchar", length: 20 }) + contractType!: string; + + @Column({ name: "previous_contract_id", type: "uuid", nullable: true }) + previousContractId?: string | null; + + @Column({ name: "service_type", type: "varchar", length: 30 }) + serviceType!: string; + + @Column({ name: "first_mile_enabled", type: "boolean", default: false }) + firstMileEnabled!: boolean; + + @Column({ name: "first_mile_pickup_address", type: "text", nullable: true }) + firstMilePickupAddress?: string | null; + + @Column({ name: "last_mile_enabled", type: "boolean", default: false }) + lastMileEnabled!: boolean; + + @Column({ name: "last_mile_delivery_address", type: "text", nullable: true }) + lastMileDeliveryAddress?: string | null; + + @Column({ name: "equipment_return", type: "varchar", length: 20 }) + equipmentReturn!: string; + + @Column({ name: "origin_station", type: "varchar", length: 255 }) + originStation!: string; + + @Column({ name: "destination_station", type: "varchar", length: 255 }) + destinationStation!: string; + + @Column({ + name: "cargo_total_weight_vgm", + type: "numeric", + precision: 12, + scale: 3, + }) + cargoTotalWeightVgm!: number; + + @Column({ name: "freight_type", type: "varchar", length: 20 }) + freightType!: string; + + @Column({ name: "freight_subtype", type: "varchar", length: 100, nullable: true }) + freightSubtype?: string | null; + + @Column({ name: "is_hazardous", type: "boolean", default: false }) + isHazardous!: boolean; + + @Column({ name: "is_refrigerated", type: "boolean", default: false }) + isRefrigerated!: boolean; + + @Column({ name: "trade_direction", type: "varchar", length: 10 }) + tradeDirection!: string; + + @Column({ name: "payment_currency", type: "varchar", length: 5 }) + paymentCurrency!: string; + + @Column({ name: "start_date", type: "date", nullable: true }) + startDate?: Date | null; + + @Column({ name: "end_date", type: "date", nullable: true }) + endDate?: Date | null; + + @Column({ name: "financial_terms", type: "text", nullable: true }) + financialTerms?: string | null; + + @Column({ name: "version_number", type: "int", default: 1 }) + versionNumber!: number; + + // ── container ────────────────────────────────────────────────────────── + @Column({ name: "container_type", type: "varchar", length: 10 }) + containerType!: string; + + @Column({ name: "container_quantity", type: "int" }) + containerQuantity!: number; + + @Column({ + name: "container_vgm_per_unit", + type: "numeric", + precision: 10, + scale: 3, + }) + containerVgmPerUnit!: number; + + // ── approval ─────────────────────────────────────────────────────────── + @Column({ name: "approved_by_staff_id", type: "uuid", nullable: true }) + approvedByStaffId?: string | null; + + @Column({ name: "approved_by_staff_at", type: "timestamptz", nullable: true }) + approvedByStaffAt?: Date | null; + + @Column({ name: "signed_by_director_id", type: "uuid", nullable: true }) + signedByDirectorId?: string | null; + + @Column({ name: "signed_by_director_at", type: "timestamptz", nullable: true }) + signedByDirectorAt?: Date | null; + + @Column({ name: "signed_by_ceo_id", type: "uuid", nullable: true }) + signedByCeoId?: string | null; + + @Column({ name: "signed_by_ceo_at", type: "timestamptz", nullable: true }) + signedByCeoAt?: Date | null; + + @Column({ name: "priority_score", type: "int", default: 0 }) + priorityScore!: number; + + // ── consolidation ────────────────────────────────────────────────────── + @Column({ name: "allow_consolidation", type: "boolean", default: false }) + allowConsolidation!: boolean; + + @Column({ name: "consolidation_partner_id", type: "uuid", nullable: true }) + consolidationPartnerId?: string | null; + + // ── documents (JSONB) ────────────────────────────────────────────────── + @Column({ name: "documents", type: "jsonb", nullable: true }) + documents?: Record | null; } diff --git a/apps/edr-freight-api/src/modules/minio/index.ts b/apps/edr-freight-api/src/modules/minio/index.ts new file mode 100644 index 000000000..c5891e495 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/index.ts @@ -0,0 +1,2 @@ +export * from "./minio.module"; +export * from "./minio.service"; diff --git a/apps/edr-freight-api/src/modules/minio/minio.config.ts b/apps/edr-freight-api/src/modules/minio/minio.config.ts new file mode 100644 index 000000000..10482a325 --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from "@nestjs/config"; + +export const minioConfig = registerAs("minio", () => ({ + endPoint: process.env.MINIO_ENDPOINT || "minio-dev.smart.aaca.gov.et", + port: parseInt(process.env.MINIO_PORT || "443", 10), + useSSL: process.env.MINIO_USE_SSL !== "false", + accessKey: process.env.MINIO_ACCESS_KEY || "", + secretKey: process.env.MINIO_SECRET_KEY || "", + bucket: process.env.MINIO_BUCKET || "fhc", +})); diff --git a/apps/edr-freight-api/src/modules/minio/minio.module.ts b/apps/edr-freight-api/src/modules/minio/minio.module.ts new file mode 100644 index 000000000..d4745beaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { minioConfig } from "./minio.config"; +import { MinioService } from "./minio.service"; + +@Module({ + imports: [ConfigModule.forFeature(minioConfig)], + providers: [MinioService], + exports: [MinioService], +}) +export class MinioModule {} diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts new file mode 100644 index 000000000..ac6fe7e5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -0,0 +1,65 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { ConfigType } from "@nestjs/config"; +import { Client } from "minio"; +import { minioConfig } from "./minio.config"; + +@Injectable() +export class MinioService { + private readonly client: Client; + private readonly logger = new Logger(MinioService.name); + private readonly bucket: string; + + constructor( + @Inject(minioConfig.KEY) + private readonly config: ConfigType, + ) { + console.log('[MinioService] Configuration loaded:', { + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey ? '***HIDDEN***' : 'EMPTY', + bucket: config.bucket, + }); + this.bucket = config.bucket; + this.client = new Client({ + endPoint: config.endPoint, + port: config.port, + useSSL: config.useSSL, + accessKey: config.accessKey, + secretKey: config.secretKey, + }); + } + + async uploadFile( + objectName: string, + buffer: Buffer, + contentType: string, + ): Promise { + try { + await this.client.putObject(this.bucket, objectName, buffer, buffer.length, { + "Content-Type": contentType, + }); + this.logger.log(`File uploaded successfully: ${objectName}`); + return this.getPublicUrl(objectName); + } catch (error) { + this.logger.error(`Failed to upload file ${objectName}:`, error); + throw error; + } + } + + getPublicUrl(objectName: string): string { + const protocol = this.config.useSSL ? "https" : "http"; + return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`; + } + + async deleteFile(objectName: string): Promise { + try { + await this.client.removeObject(this.bucket, objectName); + this.logger.log(`File deleted successfully: ${objectName}`); + } catch (error) { + this.logger.error(`Failed to delete file ${objectName}:`, error); + throw error; + } + } +} diff --git a/apps/edr-freight-api/test-minio-upload.js b/apps/edr-freight-api/test-minio-upload.js new file mode 100644 index 000000000..4b793b504 --- /dev/null +++ b/apps/edr-freight-api/test-minio-upload.js @@ -0,0 +1,45 @@ +const { Client } = require('minio'); + +const config = { + endPoint: 'minio-dev.smart.aaca.gov.et', + port: 443, + useSSL: true, + accessKey: 'f2f22b0ea929cebd5567ed0c71ec351b', + secretKey: 'xxHnjRsb90suQZZdOtEcXJXls4nj0A2anMetb1kY', + bucket: 'fhc', +}; + +const filePath = '/home/marshal/Desktop/EDR/bash/download.jpeg'; +const objectName = `test-upload-${Date.now()}.jpeg`; + +console.log('Testing MinIO upload...'); +console.log('Endpoint:', `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}`); +console.log('Bucket:', config.bucket); +console.log('File:', filePath); +console.log('Object:', objectName); +console.log(''); + +const client = new Client(config); + +const fs = require('fs'); + +try { + const fileBuffer = fs.readFileSync(filePath); + console.log('File size:', fileBuffer.length, 'bytes'); + + client.putObject(config.bucket, objectName, fileBuffer, fileBuffer.length, { 'Content-Type': 'image/jpeg' }) + .then(() => { + const url = `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}/${config.bucket}/${objectName}`; + console.log('✓ Upload successful!'); + console.log('URL:', url); + }) + .catch(err => { + console.error('✗ Upload failed:', err.message); + if (err.code === 'InvalidAccessKeyId') { + console.error('The access key does not exist on the MinIO server.'); + console.error('Contact your MinIO administrator for valid credentials.'); + } + }); +} catch (err) { + console.error('Error reading file:', err.message); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca17749d4..dd612d0ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: dotenv: specifier: ^17.4.2 version: 17.4.2 + minio: + specifier: 7.1.3 + version: 7.1.3 pg: specifier: ^8.13.0 version: 8.20.0 @@ -123,6 +126,9 @@ importers: '@types/jest': specifier: ^29.5.13 version: 29.5.14 + '@types/multer': + specifier: ^2.1.0 + version: 2.1.0 '@types/node': specifier: ^20.14.0 version: 20.19.41 @@ -3781,6 +3787,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.1.0': + resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} + '@types/node@14.18.63': resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} @@ -15183,6 +15192,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@2.1.0': + dependencies: + '@types/express': 5.0.6 + '@types/node@14.18.63': {} '@types/node@20.19.41': From 2064e6efda9bf1794e3bddcd7918fc0cbafda506 Mon Sep 17 00:00:00 2001 From: marshal Date: Sat, 23 May 2026 10:00:40 +0300 Subject: [PATCH 2/2] change the schema from public to freight --- .../src/modules/billing/entities/invoice.entity.ts | 2 +- .../src/modules/bookings/entities/booking.entity.ts | 2 +- .../src/modules/consignments/entities/consignment.entity.ts | 2 +- .../src/modules/customers/entities/customer.entity.ts | 2 +- .../dropdown-settings/entities/dropdown-option.entity.ts | 2 +- .../dropdown-settings/entities/dropdown-setting.entity.ts | 2 +- .../file-upload-settings/entities/file-upload-field.entity.ts | 2 +- .../file-upload-settings/entities/file-upload-setting.entity.ts | 2 +- .../src/modules/tracking/entities/tracking-event.entity.ts | 2 +- .../edr-freight-api/src/modules/trains/entities/train.entity.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index 0031834f5..e2a6f7cc2 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "invoices" }) +@Entity({schema:"freight", name: "invoices" }) export class Invoice extends BaseEntity { @Column({ name: "booking_id", type: "uuid" }) bookingId!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 54f678d6a..c256ef099 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -1,7 +1,7 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity } from "typeorm"; -@Entity({ name: "bookings" }) +@Entity({ schema:"freight",name: "bookings" }) export class Booking extends BaseEntity { // ── core ─────────────────────────────────────────────────────────────── @Column({ name: "reference", type: "varchar", length: 64, unique: true }) diff --git a/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts b/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts index 6224db4cc..3b7ee222b 100644 --- a/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts +++ b/apps/edr-freight-api/src/modules/consignments/entities/consignment.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "consignments" }) +@Entity({schema:"freight", name: "consignments" }) export class Consignment extends BaseEntity { @Column({ name: "booking_id", type: "uuid" }) bookingId!: string; diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts index 8a632f508..98a248c97 100644 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts @@ -4,7 +4,7 @@ import { Column, Entity } from "typeorm"; export type CustomerStatus = "Active" | "Pending" | "Inactive"; export type CustomerType = "Importer" | "Exporter" | "Supplier"; -@Entity({ name: "customers" }) +@Entity({schema:"freight", name: "customers" }) export class Customer extends BaseEntity { @Column({ name: "name", type: "varchar", length: 256 }) name!: string; diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts index 6f88c27a5..619031672 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-option.entity.ts @@ -15,7 +15,7 @@ export interface DropdownOptionMeta { badge?: string; } -@Entity({ name: "dropdown_options" }) +@Entity({schema:"freight", name: "dropdown_options" }) @Index(["settingId", "value"], { unique: true }) export class DropdownOption extends BaseEntity { @ManyToOne(() => DropdownSetting, (setting) => setting.children, { diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts index 9d4a7a7e7..5c87c8125 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/entities/dropdown-setting.entity.ts @@ -12,7 +12,7 @@ export interface DropdownSettingMeta { version?: string; } -@Entity({ name: "dropdown_settings" }) +@Entity({schema:"freight", name: "dropdown_settings" }) @Index(["code"], { unique: true }) export class DropdownSetting extends BaseEntity { @Column({ name: "code", type: "varchar", length: 128, unique: true }) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts index 157778c0e..e76c5716b 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-field.entity.ts @@ -10,7 +10,7 @@ import { import { FileUploadSetting } from "./file-upload-setting.entity"; -@Entity({ name: "file_upload_fields" }) +@Entity({schema:"freight", name: "file_upload_fields" }) @Index(["settingId", "fileKey"], { unique: true }) @Check(`"max_files" > 0`) @Check(`"max_size_mb" > 0`) diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts index 5009cad98..2318078c1 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/entities/file-upload-setting.entity.ts @@ -8,7 +8,7 @@ import { import { FileUploadField } from "./file-upload-field.entity"; -@Entity({ name: "file_upload_settings" }) +@Entity({ schema:"freight",name: "file_upload_settings" }) @Index(["code"], { unique: true }) export class FileUploadSetting extends BaseEntity { @Column({ diff --git a/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts b/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts index aed5420d7..40e0594ae 100644 --- a/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts +++ b/apps/edr-freight-api/src/modules/tracking/entities/tracking-event.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "tracking_events" }) +@Entity({schema:"freight", name: "tracking_events" }) export class TrackingEvent extends BaseEntity { @Column({ name: "consignment_id", type: "uuid" }) consignmentId!: string; diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index c14b66ad0..c12478ec2 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; import { Column, Entity } from "typeorm"; -@Entity({ name: "trains" }) +@Entity({ schema:"freight",name: "trains" }) export class Train extends BaseEntity { @Column({ name: "code", type: "varchar", length: 32, unique: true }) code!: string;