From 9432814e3ba31c48851afc67f2f3f75b8d2b8d62 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 25 May 2026 10:19:08 +0300 Subject: [PATCH] implement file module and add files relation to booking entity and populate on fetch --- apps/edr-freight-api/src/app.module.ts | 2 + .../modules/bookings/bookings.controller.ts | 3 +- .../src/modules/bookings/bookings.module.ts | 4 +- .../modules/bookings/bookings.repository.ts | 31 +++++++ .../src/modules/bookings/bookings.service.ts | 45 ++-------- .../bookings/entities/booking.entity.ts | 12 ++- .../src/modules/files/entities/file.entity.ts | 26 ++++++ .../src/modules/files/files.controller.ts | 28 +++++++ .../src/modules/files/files.module.ts | 16 ++++ .../src/modules/files/files.repository.ts | 28 +++++++ .../src/modules/files/files.service.ts | 84 +++++++++++++++++++ .../src/modules/minio/minio.service.ts | 10 +++ 12 files changed, 245 insertions(+), 44 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/files/entities/file.entity.ts create mode 100644 apps/edr-freight-api/src/modules/files/files.controller.ts create mode 100644 apps/edr-freight-api/src/modules/files/files.module.ts create mode 100644 apps/edr-freight-api/src/modules/files/files.repository.ts create mode 100644 apps/edr-freight-api/src/modules/files/files.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 60ca553f7..bc61c576e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -8,6 +8,7 @@ import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; +import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; import { TrainsModule } from "./modules/trains/trains.module"; import { CustomersModule } from "./modules/customers/customers.module"; @@ -31,6 +32,7 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set SharedAuthModule, IamModule.forRoot(), BookingsModule, + FilesModule, ConsignmentsModule, TrainsModule, CustomersModule, 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 98f8d3627..f78432ec9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -39,7 +39,7 @@ export class BookingsController { @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.", + "Each uploaded file is saved as a row in the files table (resource=bookings).", type: CreateBookingDto, }) create( @@ -156,4 +156,5 @@ export class BookingsController { 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 eb6d6d60f..fddb99945 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,14 +1,14 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; -import { MinioModule } from "../minio/minio.module"; +import { FilesModule } from "../files/files.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]), MinioModule], + imports: [TypeOrmModule.forFeature([Booking]), FilesModule], 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 ba9910fcd..6cf3b2149 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -4,6 +4,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { In, IsNull, Not, Repository } from "typeorm"; import { Booking } from "./entities/booking.entity"; +import { FileRecord } from "../files/entities/file.entity"; @Injectable() export class BookingsRepository extends BaseRepository { @@ -19,6 +20,36 @@ export class BookingsRepository extends BaseRepository { return this.repository.findOne({ where: { reference } }); } + /** Find a booking by reference with associated files (polymorphic join). */ + async findByReferenceWithFiles(reference: string): Promise { + const booking = await this.repository + .createQueryBuilder("booking") + .where("booking.reference = :reference", { reference }) + .leftJoinAndMapMany( + "booking.files", + FileRecord, + "file", + "file.resource_id = booking.id AND file.resource = 'bookings'" + ) + .getOne(); + return booking ?? null; + } + + /** Find a booking by ID with associated files (polymorphic join). */ + async findByIdWithFiles(id: string): Promise { + const booking = await this.repository + .createQueryBuilder("booking") + .where("booking.id = :id", { id }) + .leftJoinAndMapMany( + "booking.files", + FileRecord, + "file", + "file.resource_id = booking.id AND file.resource = 'bookings'" + ) + .getOne(); + return booking ?? null; + } + /** Find a compatible consolidation partner for the given booking. */ async findConsolidationPartner(booking: Booking): Promise { return this.repository.findOne({ 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 57a5b2bd1..2493b5f89 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -6,7 +6,7 @@ import { } from "@nestjs/common"; import { IsNull, Not } from "typeorm"; -import { MinioService } from "../minio/minio.service"; +import { FilesService } from "../files/files.service"; import { BookingsRepository } from "./bookings.repository"; import { CreateBookingDto } from "./dto/create-booking.dto"; import { FilterBookingDto } from "./dto/filter-booking.dto"; @@ -28,7 +28,7 @@ const HIGH_VOLUME_THRESHOLD_TONS = 500; export class BookingsService { constructor( private readonly bookingsRepository: BookingsRepository, - private readonly minioService: MinioService, + private readonly filesService: FilesService, ) {} // ── helpers ────────────────────────────────────────────────────────── @@ -97,29 +97,6 @@ export class BookingsService { return warnings; } - /** 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 ───────────────────────────────────────────────────────────── @@ -157,15 +134,11 @@ export class BookingsService { 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; + await this.filesService.uploadMany(booking.id, "bookings", files); } catch (err) { console.error('[BookingsService] File upload failed, booking still created:', err); warnings.push('File upload failed — booking was created without attached files.'); @@ -210,10 +183,8 @@ export class BookingsService { const overweightWarnings = this.checkOverweight(containers, direction); warnings.push(...overweightWarnings); - // Merge documents - upload new files to MinIO if (files.length > 0) { - const newDocs = await this.buildDocuments(id, files); - updates.documents = { ...(existing.documents ?? {}), ...newDocs }; + await this.filesService.uploadMany(id, "bookings", files); } const booking = await this.bookingsRepository.update(id, updates); @@ -256,18 +227,18 @@ export class BookingsService { return { items, total }; } - /** Get a single booking by ID, throwing if not found. */ + /** Get a single booking by ID with files, throwing if not found. */ async findById(id: string): Promise { - const booking = await this.bookingsRepository.findById(id); + const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { throw new NotFoundException(`Booking ${id} not found`); } return booking; } - /** Find booking by reference. */ + /** Find booking by reference with files. */ async findByReference(reference: string): Promise { - const booking = await this.bookingsRepository.findByReference(reference); + const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); if (!booking) { throw new NotFoundException(`Booking with reference "${reference}" not found`); } 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 8c7492859..75f4abe96 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,5 +1,6 @@ import { BaseEntity } from "@edr/api-common"; -import { Column, Entity } from "typeorm"; +import { Column, Entity, OneToMany } from "typeorm"; +import { FileRecord } from "../../files/entities/file.entity"; @Entity({ schema:"freight",name: "bookings" }) export class Booking extends BaseEntity { @@ -138,7 +139,10 @@ export class Booking extends BaseEntity { @Column({ name: "consolidation_partner_id", type: "uuid", nullable: true }) consolidationPartnerId?: string | null; - // ── documents (JSONB) ────────────────────────────────────────────────── - @Column({ name: "documents", type: "jsonb", nullable: true }) - documents?: Record | null; + // ── files ──────────────────────────────────────────────────────────── + @OneToMany(() => FileRecord, (file) => file.resourceId, { + createForeignKeyConstraints: false, + }) + files?: FileRecord[]; + } diff --git a/apps/edr-freight-api/src/modules/files/entities/file.entity.ts b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts new file mode 100644 index 000000000..221b7c29b --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/entities/file.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +@Entity({ schema: "freight", name: "files" }) +export class FileRecord extends BaseEntity { + @Column({ name: "resource_id", type: "uuid" }) + resourceId!: string; + + @Column({ name: "resource", type: "varchar", length: 100 }) + resource!: string; + + @Column({ name: "code", type: "varchar", length: 100 }) + code!: string; + + @Column({ name: "name", type: "varchar", length: 500 }) + name!: string; + + @Column({ name: "url", type: "text" }) + url!: string; + + @Column({ name: "size", type: "integer" }) + size!: number; + + @Column({ name: "mime_type", type: "varchar", length: 255 }) + mimeType!: string; +} diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts new file mode 100644 index 000000000..acf274ff0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Response } from "express"; + +import { FilesService } from "./files.service"; + +@ApiTags("files") +@Controller("files") +export class FilesController { + constructor(private readonly filesService: FilesService) {} + + @Get(":fileId") + @ApiOperation({ + summary: "Download a file by ID", + description: + "Global endpoint — streams any uploaded file directly from MinIO by its UUID. " + + "No resource context (e.g. booking ID) required.", + }) + async download( + @Param("fileId", ParseUUIDPipe) fileId: string, + @Res() res: Response, + ) { + const { stream, record } = await this.filesService.streamById(fileId); + res.setHeader("Content-Type", record.mimeType); + res.setHeader("Content-Disposition", `attachment; filename="${record.name}"`); + stream.pipe(res); + } +} diff --git a/apps/edr-freight-api/src/modules/files/files.module.ts b/apps/edr-freight-api/src/modules/files/files.module.ts new file mode 100644 index 000000000..fa04c9fa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { MinioModule } from "../minio/minio.module"; +import { FilesController } from "./files.controller"; +import { FilesRepository } from "./files.repository"; +import { FilesService } from "./files.service"; +import { FileRecord } from "./entities/file.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([FileRecord]), MinioModule], + controllers: [FilesController], + providers: [FilesService, FilesRepository], + exports: [FilesService], +}) +export class FilesModule {} diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts new file mode 100644 index 000000000..2ec2b94a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -0,0 +1,28 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { FileRecord } from "./entities/file.entity"; + +@Injectable() +export class FilesRepository extends BaseRepository { + constructor( + @InjectRepository(FileRecord) + repository: Repository, + ) { + super(repository); + } + + findByResource(resourceId: string, resource: string): Promise { + return this.repository.find({ where: { resourceId, resource } }); + } + + findByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + return this.repository.findOne({ where: { resourceId, resource, code } }); + } +} diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts new file mode 100644 index 000000000..e08fce72b --- /dev/null +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -0,0 +1,84 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Readable } from "stream"; + +import { MinioService } from "../minio/minio.service"; +import { FilesRepository } from "./files.repository"; +import { FileRecord } from "./entities/file.entity"; + +export interface CreateFileInput { + resourceId: string; + resource: string; + code: string; + file: Express.Multer.File; +} + +@Injectable() +export class FilesService { + constructor( + private readonly filesRepository: FilesRepository, + private readonly minioService: MinioService, + ) {} + + async upload(input: CreateFileInput): Promise { + const { resourceId, resource, code, file } = input; + const objectName = `${resource}/${resourceId}/${Date.now()}_${file.originalname}`; + const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype); + + return this.filesRepository.create({ + resourceId, + resource, + code, + name: file.originalname, + url, + size: file.size, + mimeType: file.mimetype, + }); + } + + async uploadMany( + resourceId: string, + resource: string, + files: Express.Multer.File[], + ): Promise { + return Promise.all( + files.map((file) => + this.upload({ resourceId, resource, code: file.fieldname, file }), + ), + ); + } + + async findById(id: string): Promise { + const record = await this.filesRepository.findById(id); + if (!record) throw new NotFoundException(`File ${id} not found`); + return record; + } + + findByResource(resourceId: string, resource: string): Promise { + return this.filesRepository.findByResource(resourceId, resource); + } + + async findByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + const record = await this.filesRepository.findByCode(resourceId, resource, code); + if (!record) + throw new NotFoundException( + `File with code "${code}" not found for ${resource} ${resourceId}`, + ); + return record; + } + + async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> { + const record = await this.findById(id); + const objectName = this.extractObjectName(record.url); + const stream = await this.minioService.getFileStream(objectName); + return { stream, record }; + } + + private extractObjectName(url: string): string { + const parts = url.split("/"); + return parts.slice(4).join("/"); + } +} diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts index ac6fe7e5a..eb75f18a8 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.service.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { Client } from "minio"; +import { Readable } from "stream"; import { minioConfig } from "./minio.config"; @Injectable() @@ -62,4 +63,13 @@ export class MinioService { throw error; } } + + async getFileStream(objectName: string): Promise { + try { + return this.client.getObject(this.bucket, objectName); + } catch (error) { + this.logger.error(`Failed to get file ${objectName}:`, error); + throw error; + } + } }