add onboarding

This commit is contained in:
yaschalew
2026-05-26 10:15:59 +03:00
73 changed files with 6030 additions and 912 deletions

View File

@@ -30,6 +30,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",
@@ -46,6 +47,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",

View File

@@ -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";
@@ -17,6 +18,7 @@ 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 { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service";
@Module({
imports: [
@@ -32,6 +34,7 @@ import { OtpModule } from './modules/otp/otp.module';
SharedAuthModule,
IamModule.forRoot(),
BookingsModule,
FilesModule,
ConsignmentsModule,
TrainsModule,
CustomersModule,
@@ -44,9 +47,13 @@ import { OtpModule } from './modules/otp/otp.module';
],
})
export class AppModule implements OnApplicationBootstrap {
constructor(private readonly seeder: DataSeeder) {}
constructor(
private readonly seeder: DataSeeder,
private readonly dropdownSettingsService: DropdownSettingsService,
) {}
async onApplicationBootstrap() {
await this.seeder.run();
await this.dropdownSettingsService.seedDefaultStations();
}
}

View File

@@ -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;

View File

@@ -6,42 +6,174 @@ 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 {
ApiBearerAuth,
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")
@ApiBearerAuth()
export class BookingsController {
constructor(private readonly bookingsService: BookingsService) {}
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). " +
"Each uploaded file is saved as a row in the files table (resource=bookings).",
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);
}
}

View File

@@ -1,13 +1,14 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
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])],
imports: [TypeOrmModule.forFeature([Booking]), FilesModule],
controllers: [BookingsController],
providers: [BookingsService, BookingsRepository],
exports: [BookingsService],

View File

@@ -1,9 +1,10 @@
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";
import { FileRecord } from "../files/entities/file.entity";
@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
@@ -18,4 +19,76 @@ export class BookingsRepository extends BaseRepository<Booking> {
findByReference(reference: string): Promise<Booking | null> {
return this.repository.findOne({ where: { reference } });
}
/** Find a booking by reference with associated files (polymorphic join). */
async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
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<Booking | null> {
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<Booking | null> {
return this.repository.findOne({
where: {
allowConsolidation: true,
// Check if containers JSONB contains at least one 20FT entry with odd qty
containers: Not(IsNull()),
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<void> {
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<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: "PENDING_CONSOLIDATION",
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: null,
status: "PENDING_CONSOLIDATION",
} as never);
}
}

View File

@@ -1,20 +1,196 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { IsNull, Not } from "typeorm";
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";
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 filesService: FilesService,
) {}
// ── helpers ──────────────────────────────────────────────────────────
/** Resolve auto-consolidation flag. */
private resolveConsolidation(
containers: Array<{ type: string; qty: number }> | undefined | null,
explicit?: boolean,
): boolean {
if (explicit === false) return false;
if (!containers || containers.length === 0) return explicit ?? false;
// Auto-enable if any 20FT container has odd quantity
const needsConsolidation = containers.some(
(c) => c.type === "20FT" && c.qty % 2 !== 0
);
if (needsConsolidation) 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(
containers: Array<{ type: string; qty: number }>,
): number {
return containers.reduce((total, container) => {
if (container.type === "40FT") {
return total + container.qty;
}
// 20FT: 1 wagon per 2 containers (rounded up)
return total + Math.ceil(container.qty / 2);
}, 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 ─────────────────────────────────────────────────────────────
/** Create a new freight booking. */
async create(dto: CreateBookingDto): Promise<Booking> {
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.containers,
dto.allowConsolidation,
);
const priorityScore = this.calculatePriorityScore(
dto.paymentCurrency,
dto.serviceType,
);
const overweightWarnings = this.checkOverweight(
dto.containers,
dto.tradeDirection,
);
warnings.push(...overweightWarnings);
const wagonCount = this.calculateWagonCount(dto.containers);
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,
});
if (files.length > 0) {
try {
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.');
}
}
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<string, unknown> = { ...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 containers changed
const containers = dto.containers ?? existing.containers ?? [];
updates.allowConsolidation = this.resolveConsolidation(
containers,
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);
if (files.length > 0) {
await this.filesService.uploadMany(id, "bookings", files);
}
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,30 +199,327 @@ export class BookingsService {
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
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.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 };
}
/** 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<Booking> {
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;
}
/** Soft-delete a booking. */
/** Find booking by reference with files. */
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(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<void> {
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<Booking> {
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<Booking> {
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<Booking> {
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<Booking> {
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<Booking> {
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<Booking> {
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<Booking> {
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<Booking> {
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<Booking> {
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");
}
// Check if any 20FT container has odd quantity
const hasOdd20FT = booking.containers?.some(
(c) => c.type === "20FT" && c.qty % 2 !== 0
) ?? false;
if (!hasOdd20FT) {
throw new BadRequestException(
"Only bookings with odd-quantity 20FT containers 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 };
}
}

View File

@@ -1,33 +1,215 @@
import { Freight } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsEnum,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} 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 ContainerItem {
@ApiProperty({ enum: CONTAINER_TYPES, description: "Container type (20FT or 40FT)" })
@IsIn([...CONTAINER_TYPES])
type!: string;
@ApiProperty({ description: "Quantity of containers", minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
qty!: number;
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgm!: number;
}
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;
// ── containers ────────────────────────────────────────────────────────
@ApiProperty({ type: [ContainerItem], description: "Array of container specifications" })
@IsArray()
@ValidateNested({ each: true })
@Type(() => ContainerItem)
containers!: ContainerItem[];
@ApiPropertyOptional({
default: false,
description: "Auto-set to true when any 20FT container has odd quantity. User may override.",
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
allowConsolidation?: boolean;
}

View File

@@ -1,22 +1,81 @@
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,
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({ 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()

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateBookingDto } from "./create-booking.dto";
export class UpdateBookingDto extends PartialType(CreateBookingDto) {}

View File

@@ -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;
}

View File

@@ -1,9 +1,10 @@
import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
import { Column, Entity } from "typeorm";
import { Column, Entity, OneToMany } from "typeorm";
import { FileRecord } from "../../files/entities/file.entity";
@Entity({ name: "bookings" })
@Entity({ schema:"freight",name: "bookings" })
export class Booking extends BaseEntity {
// ── core ───────────────────────────────────────────────────────────────
@Column({ name: "reference", type: "varchar", length: 64, unique: true })
reference!: string;
@@ -13,13 +14,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 +31,118 @@ 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;
// ── containers ─────────────────────────────────────────────────────────
@Column({ name: "containers", type: "jsonb", nullable: true })
containers!: Array<{ type: string; qty: number; vgm: number }> | null;
// ── 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;
// ── files ────────────────────────────────────────────────────────────
@OneToMany(() => FileRecord, (file) => file.resourceId, {
createForeignKeyConstraints: false,
})
files?: FileRecord[];
}

View File

@@ -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;

View File

@@ -1,5 +1,6 @@
// src/modules/customers/customers.controller.ts
import {
Body,
Controller,
Delete,
Get,
@@ -9,49 +10,75 @@ import {
ParseUUIDPipe,
Patch,
Post,
Body,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiOperation } from "@nestjs/swagger";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@ApiTags("customers")
@Controller("customers")
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
@ApiOperation({ summary: "Create a new customer" })
create(@Body() dto: CreateCustomerDto) {
return this.customersService.create(dto);
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
return this.customersService.create(createCustomerDto);
}
@Get()
@ApiOperation({ summary: "List all customers" })
findAll() {
findAll(): Promise<Customer[]> {
return this.customersService.findAll();
}
@Get("stats")
@ApiOperation({ summary: "Get customer statistics" })
getStats(): Promise<{ total: number; withVatNumber: number }> {
return this.customersService.getStats();
}
@Get("search")
searchByName(@Query("name") name: string): Promise<Customer[]> {
return this.customersService.searchByName(name);
}
@Get("email/:email")
findByEmail(@Param("email") email: string): Promise<Customer> {
return this.customersService.findByEmail(email);
}
@Get("vat/:vatNumber")
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
return this.customersService.findByVatNumber(vatNumber);
}
@Get(":id")
@ApiOperation({ summary: "Get a customer by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
return this.customersService.findById(id);
}
@Get("user/:userId")
findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
return this.customersService.findByUserId(userId);
}
@Patch(":id")
@ApiOperation({ summary: "Update a customer" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
) {
): Promise<Customer> {
return this.customersService.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Soft-delete a customer" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.customersService.remove(id);
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
return this.customersService.delete(id);
}
}
}

View File

@@ -1,21 +1,117 @@
import { BaseRepository } from "@edr/api-common";
// import { BaseRepository } from "@edr/api-common";
// import { EntityRepository } from "typeorm";
// src/modules/customers/customers.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
import { Customer } from "./entities/customer.entity";
import { CreateCustomerDto } from "./dto/create-customer.dto";
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
@Injectable()
export class CustomersRepository extends BaseRepository<Customer> {
export class CustomersRepository {
constructor(
@InjectRepository(Customer)
repository: Repository<Customer>,
) {
super(repository);
private readonly repository: Repository<Customer>,
) { }
async create(dto: CreateCustomerDto): Promise<Customer> {
const customer = this.repository.create(dto);
return await this.repository.save(customer);
}
/** Find a customer by their unique email. */
findByEmail(email: string): Promise<Customer | null> {
return this.repository.findOne({ where: { email } });
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
return await this.repository.find(options);
}
}
async findById(id: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
}
async findByUserId(userId: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
}
async findByEmail(email: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
}
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
}
async findByName(name: string): Promise<Customer[]> {
return await this.repository
.createQueryBuilder("customer")
.where("customer.name ILIKE :name", { name: `%${name}%` })
.getMany();
}
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
if (!email && !vatNumber) return null;
const queryBuilder = this.repository.createQueryBuilder('customer');
if (email && vatNumber) {
queryBuilder.where('customer.email = :email', { email })
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
} else if (email) {
queryBuilder.where('customer.email = :email', { email });
} else if (vatNumber) {
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
}
return await queryBuilder.getOne();
}
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
await this.repository.update(id, updates);
return this.findById(id);
}
async delete(id: string): Promise<boolean> {
const result = await this.repository.delete(id);
return (result.affected ?? 0) > 0;
}
async count(where?: any): Promise<number> {
if (where?.createdAt) {
const result = await this.repository
.createQueryBuilder('customer')
.where('customer.createdAt >= :date', { date: where.createdAt })
.getCount();
return result;
}
return await this.repository.count();
}
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
const queryBuilder = this.repository.createQueryBuilder('customer')
.where('customer.email = :email', { email });
if (vatNumber) {
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
}
const count = await queryBuilder.getCount();
return count > 0;
}
async countWithVatNumber(): Promise<number> {
const count = await this.repository
.createQueryBuilder('customer')
.where('customer.vatNumber IS NOT NULL')
.andWhere("customer.vatNumber != ''")
.getCount();
return count;
}
getRepository(): Repository<Customer> {
return this.repository;
}
softDelete(id: string): any {
return id;
}
}

View File

@@ -1,7 +1,8 @@
import {
ConflictException,
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { CustomersRepository } from "./customers.repository";
@@ -13,34 +14,97 @@ import { Customer } from "./entities/customer.entity";
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
/** Create a new customer */
async create(dto: CreateCustomerDto): Promise<Customer> {
const existing = await this.customersRepository.findByEmail(dto.email);
if (existing) {
const exists = await this.customersRepository.existsByUniqueFields(
dto.email,
dto.vatNumber,
);
if (exists) {
throw new ConflictException(
`Customer with email "${dto.email}" already exists`,
"Customer with same email or VAT number already exists",
);
}
if (dto.vatNumber && dto.vatNumber.length !== 10) {
throw new BadRequestException("VAT number must be exactly 10 digits");
}
return this.customersRepository.create(dto);
}
/** Get all customers */
findAll(): Promise<Customer[]> {
return this.customersRepository.findAll({ order: { name: "ASC" } });
return this.customersRepository.findAll({
order: { companyName: "ASC" },
});
}
/** Get customer by ID */
async findById(id: string): Promise<Customer> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
throw new NotFoundException(`Customer ${id} not found`);
throw new NotFoundException(`Customer with ID ${id} not found`);
}
return customer;
}
async findByUserId(userId: string): Promise<Customer> {
const customer = await this.customersRepository.findByUserId(userId);
if (!customer) {
throw new NotFoundException(`Customer with ID ${userId} not found`);
}
return customer;
}
/** Get customer by email */
async findByEmail(email: string): Promise<Customer> {
const customer = await this.customersRepository.findByEmail(email);
if (!customer) {
throw new NotFoundException(`Customer with email ${email} not found`);
}
return customer;
}
/** Get customer by VAT number */
async findByVatNumber(vatNumber: string): Promise<Customer> {
const customer = await this.customersRepository.findByVatNumber(vatNumber);
if (!customer) {
throw new NotFoundException(
`Customer with VAT number ${vatNumber} not found`,
);
}
return customer;
}
/** Search customers by name */
searchByName(name: string): Promise<Customer[]> {
return this.customersRepository.findByName(name);
}
/** Update customer */
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
await this.findById(id);
// Validate VAT number if provided
if (dto.vatNumber && dto.vatNumber.length !== 10) {
throw new BadRequestException("VAT number must be exactly 10 digits");
}
// Check email conflict
if (dto.email) {
const conflict = await this.customersRepository.findByEmail(dto.email);
if (conflict && conflict.id !== id) {
const existing = await this.customersRepository.findByEmail(dto.email);
if (existing && existing.userId !== id) {
throw new ConflictException(
`Customer with email "${dto.email}" already exists`,
);
@@ -48,14 +112,29 @@ export class CustomersService {
}
const updated = await this.customersRepository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Customer ${id} not found`);
}
return updated;
}
/** Delete customer (soft delete) */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.customersRepository.softDelete(id);
}
}
/** Get customer statistics */
async getStats(): Promise<{ total: number; withVatNumber: number }> {
const total = await this.customersRepository.count();
const withVatNumber = await this.customersRepository.countWithVatNumber();
return { total, withVatNumber };
}
delete(id: string): any {
return id;
}
}

View File

@@ -4,8 +4,12 @@ import {
IsOptional,
IsString,
MaxLength,
IsNotEmpty,
Length,
Matches,
} from "class-validator";
// Enums
export enum CustomerStatusDto {
Active = "Active",
Pending = "Pending",
@@ -18,23 +22,57 @@ export enum CustomerTypeDto {
Supplier = "Supplier",
}
// DTO
export class CreateCustomerDto {
// Basic identity
@IsString()
@MaxLength(256)
name!: string;
@IsNotEmpty()
userId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName!: string;
@IsEmail()
@IsNotEmpty()
email!: string;
@IsString()
@MaxLength(32)
@IsNotEmpty()
@MaxLength(20)
phone!: string;
@IsOptional()
// Company info
@IsString()
@MaxLength(256)
company?: string;
@IsNotEmpty()
@MaxLength(200)
companyName!: string;
@IsEmail()
@IsNotEmpty()
companyEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
companyPhone!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
companyLocation!: string;
@IsString()
@IsNotEmpty()
companyAddress!: string;
// Classification
@IsOptional()
@IsEnum(CustomerTypeDto)
customerType?: CustomerTypeDto;
@@ -43,31 +81,76 @@ export class CreateCustomerDto {
@IsEnum(CustomerStatusDto)
status?: CustomerStatusDto;
// Legal identifiers
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
tinNumber!: string;
@IsString()
@IsNotEmpty()
@Length(16, 16)
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
fanNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(50)
vatNumber!: string;
// Contact person
@IsString()
@IsNotEmpty()
@MaxLength(100)
contactPersonName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
contactPersonPhone!: string;
// Management
@IsString()
@IsNotEmpty()
@MaxLength(100)
generalManagerName!: string;
@IsEmail()
@IsNotEmpty()
generalManagerEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
generalManagerPhone!: string;
// POA (Power of Attorney)
@IsOptional()
@IsString()
@MaxLength(64)
tinNumber?: string;
@MaxLength(100)
poaName?: string;
@IsOptional()
@IsString()
@MaxLength(128)
city?: string;
@MaxLength(20)
poaPhone?: string;
@IsOptional()
@IsString()
@MaxLength(128)
country?: string;
poaAddress?: string;
@IsOptional()
@IsEmail()
poaEmail?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
@MaxLength(64)
taxId?: string;
@MaxLength(100)
poaLocation?: string;
// Extra
@IsOptional()
@IsString()
notes?: string;
}
}

View File

@@ -0,0 +1,60 @@
// src/modules/customers/dto/response-customer.dto.ts
import { Customer } from '../entities/customer.entity';
export class ResponseCustomerDto {
UserId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
companyLocation: string;
companyAddress: string;
contactPersonName: string;
contactPersonPhone: string;
tinNumber: string;
vatNumber?: string;
fanNumber: string;
generalManagerName: string;
generalManagerEmail: string;
generalManagerPhone: string;
poaName?: string;
poaPhone?: string;
poaAddress?: string;
poaEmail?: string;
poaLocation?: string;
notes?: string;
createdAt: Date;
updatedAt: Date;
constructor(customer: Customer) {
this.UserId = customer.userId;
this.firstName = customer.firstName;
this.lastName = customer.lastName;
this.email = customer.email;
this.phone = customer.phone;
this.companyName = customer.companyName;
this.companyEmail = customer.companyEmail;
this.companyPhone = customer.companyPhone;
this.companyLocation = customer.companyLocation;
this.companyAddress = customer.companyAddress;
this.contactPersonName = customer.contactPersonName;
this.contactPersonPhone = customer.contactPersonPhone;
this.tinNumber = customer.tinNumber;
this.vatNumber = customer.vatNumber;
this.fanNumber = customer.fanNumber;
this.generalManagerName = customer.generalManagerName;
this.generalManagerEmail = customer.generalManagerEmail;
this.generalManagerPhone = customer.generalManagerPhone;
this.poaName = customer.poaName ?? '';
this.poaPhone = customer.poaPhone ?? '';
this.poaAddress = customer.poaAddress ?? '';
this.poaEmail = customer.poaEmail ?? '';
this.poaLocation = customer.poaLocation ?? '';
this.notes = customer.notes ?? '';
this.createdAt = customer.createdAt;
this.updatedAt = customer.updatedAt;
}
}

View File

@@ -1,5 +1,9 @@
import { PartialType } from "@nestjs/swagger";
// src/modules/customers/dto/update-customer.dto.ts
import { PartialType } from '@nestjs/swagger';
import { CreateCustomerDto } from './create-customer.dto';
import { CreateCustomerDto } from "./create-customer.dto";
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
email?: string;
vatNumber?: string;
// Add any other properties you need to access directly
}

View File

@@ -1,54 +1,100 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
import {
Column,
Entity,
CreateDateColumn,
UpdateDateColumn,
Index,
BaseEntity,
PrimaryGeneratedColumn,
} from "typeorm";
export type CustomerStatus = "Active" | "Pending" | "Inactive";
export type CustomerType = "Importer" | "Exporter" | "Supplier";
@Entity({ name: "customers" })
@Entity("customers")
export class Customer extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 256 })
name!: string;
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ name: "email", type: "varchar", length: 256, unique: true })
@Column({ type: "uuid" })
@Index()
userId!: string;
@Column({ length: 100 })
@Index()
firstName!: string;
@Column({ length: 100 })
@Index()
lastName!: string;
@Column({ unique: true, length: 150 })
@Index()
email!: string;
@Column({ name: "phone", type: "varchar", length: 32 })
@Column({ length: 20 })
phone!: string;
@Column({ name: "company", type: "varchar", length: 256, nullable: true })
company?: string | null;
@Column({ length: 200 })
@Index()
companyName!: string;
@Column({
name: "customer_type",
type: "varchar",
length: 32,
default: "Importer",
})
customerType!: CustomerType;
@Column({ length: 150 })
companyEmail!: string;
@Column({
name: "status",
type: "varchar",
length: 32,
default: "Active",
})
status!: CustomerStatus;
@Column({ length: 20 })
companyPhone!: string;
@Column({ name: "tin_number", type: "varchar", length: 64, nullable: true })
tinNumber?: string | null;
@Column({ length: 100 })
companyLocation!: string;
@Column({ name: "city", type: "varchar", length: 128, nullable: true })
city?: string | null;
@Column({ type: "text" })
companyAddress!: string;
@Column({ name: "country", type: "varchar", length: 128, nullable: true })
country?: string | null;
@Column({ length: 100 })
contactPersonName!: string;
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;
@Column({ length: 20 })
contactPersonPhone!: string;
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
taxId?: string | null;
@Column({ length: 10, unique: true })
@Index()
tinNumber!: string;
@Column({ name: "notes", type: "text", nullable: true })
notes?: string | null;
}
@Column({ length: 50, nullable: true })
vatNumber?: string;
@Column({ length: 16, unique: true })
@Index()
fanNumber!: string;
@Column({ length: 100 })
generalManagerName!: string;
@Column({ length: 150 })
generalManagerEmail!: string;
@Column({ length: 20 })
generalManagerPhone!: string;
@Column({ length: 100, nullable: true })
poaName?: string;
@Column({ length: 20, nullable: true })
poaPhone?: string;
@Column({ type: "text", nullable: true })
poaAddress?: string;
@Column({ nullable: true, length: 150 })
poaEmail?: string;
@Column({ length: 100, nullable: true })
poaLocation?: string;
@Column({ type: "text", nullable: true })
notes?: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}

View File

@@ -0,0 +1,57 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
@ApiTags("customers")
@Controller("customers")
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
@ApiOperation({ summary: "Create a new customer" })
create(@Body() dto: CreateCustomerDto) {
return this.customersService.create(dto);
}
@Get()
@ApiOperation({ summary: "List all customers" })
findAll() {
return this.customersService.findAll();
}
@Get(":id")
@ApiOperation({ summary: "Get a customer by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.customersService.findById(id);
}
@Patch(":id")
@ApiOperation({ summary: "Update a customer" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
) {
return this.customersService.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Soft-delete a customer" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.customersService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CustomersController } from "./customers.controller";
import { CustomersRepository } from "./customers.repository";
import { CustomersService } from "./customers.service";
import { Customer } from "./entities/customer.entity";
@Module({
imports: [TypeOrmModule.forFeature([Customer])],
controllers: [CustomersController],
providers: [CustomersService, CustomersRepository],
exports: [CustomersService],
})
export class CustomersModule {}

View File

@@ -0,0 +1,21 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Customer } from "./entities/customer.entity";
@Injectable()
export class CustomersRepository extends BaseRepository<Customer> {
constructor(
@InjectRepository(Customer)
repository: Repository<Customer>,
) {
super(repository);
}
/** Find a customer by their unique email. */
findByEmail(email: string): Promise<Customer | null> {
return this.repository.findOne({ where: { email } });
}
}

View File

@@ -0,0 +1,61 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { CustomersRepository } from "./customers.repository";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
async create(dto: CreateCustomerDto): Promise<Customer> {
const existing = await this.customersRepository.findByEmail(dto.email);
if (existing) {
throw new ConflictException(
`Customer with email "${dto.email}" already exists`,
);
}
return this.customersRepository.create(dto);
}
findAll(): Promise<Customer[]> {
return this.customersRepository.findAll({ order: { name: "ASC" } });
}
async findById(id: string): Promise<Customer> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
throw new NotFoundException(`Customer ${id} not found`);
}
return customer;
}
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
await this.findById(id);
if (dto.email) {
const conflict = await this.customersRepository.findByEmail(dto.email);
if (conflict && conflict.id !== id) {
throw new ConflictException(
`Customer with email "${dto.email}" already exists`,
);
}
}
const updated = await this.customersRepository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Customer ${id} not found`);
}
return updated;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.customersRepository.softDelete(id);
}
}

View File

@@ -0,0 +1,73 @@
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
export enum CustomerStatusDto {
Active = "Active",
Pending = "Pending",
Inactive = "Inactive",
}
export enum CustomerTypeDto {
Importer = "Importer",
Exporter = "Exporter",
Supplier = "Supplier",
}
export class CreateCustomerDto {
@IsString()
@MaxLength(256)
name!: string;
@IsEmail()
email!: string;
@IsString()
@MaxLength(32)
phone!: string;
@IsOptional()
@IsString()
@MaxLength(256)
company?: string;
@IsOptional()
@IsEnum(CustomerTypeDto)
customerType?: CustomerTypeDto;
@IsOptional()
@IsEnum(CustomerStatusDto)
status?: CustomerStatusDto;
@IsOptional()
@IsString()
@MaxLength(64)
tinNumber?: string;
@IsOptional()
@IsString()
@MaxLength(128)
city?: string;
@IsOptional()
@IsString()
@MaxLength(128)
country?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
@MaxLength(64)
taxId?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateCustomerDto } from "./create-customer.dto";
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {}

View File

@@ -0,0 +1,54 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity } from "typeorm";
export type CustomerStatus = "Active" | "Pending" | "Inactive";
export type CustomerType = "Importer" | "Exporter" | "Supplier";
@Entity({schema:"freight", name: "customers" })
export class Customer extends BaseEntity {
@Column({ name: "name", type: "varchar", length: 256 })
name!: string;
@Column({ name: "email", type: "varchar", length: 256, unique: true })
email!: string;
@Column({ name: "phone", type: "varchar", length: 32 })
phone!: string;
@Column({ name: "company", type: "varchar", length: 256, nullable: true })
company?: string | null;
@Column({
name: "customer_type",
type: "varchar",
length: 32,
default: "Importer",
})
customerType!: CustomerType;
@Column({
name: "status",
type: "varchar",
length: 32,
default: "Active",
})
status!: CustomerStatus;
@Column({ name: "tin_number", type: "varchar", length: 64, nullable: true })
tinNumber?: string | null;
@Column({ name: "city", type: "varchar", length: 128, nullable: true })
city?: string | null;
@Column({ name: "country", type: "varchar", length: 128, nullable: true })
country?: string | null;
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;
@Column({ name: "tax_id", type: "varchar", length: 64, nullable: true })
taxId?: string | null;
@Column({ name: "notes", type: "text", nullable: true })
notes?: string | null;
}

View File

@@ -16,6 +16,71 @@ import {
IDropdownSettingsRepository,
} from "./interfaces/dropdown-settings.repository.interface";
const STATIONS_TER_CODE = "stations_ter";
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
{
value: "inside_addis_ababa",
label: "Addis Ababa",
note: "Inside country",
order: 1,
},
{
value: "inside_adama",
label: "Adama",
note: "Inside country",
order: 2,
},
{
value: "inside_mojo",
label: "Mojo",
note: "Inside country",
order: 3,
},
{
value: "inside_awash",
label: "Awash",
note: "Inside country",
order: 4,
},
{
value: "inside_mieso",
label: "Mieso",
note: "Inside country",
order: 5,
},
{
value: "inside_dire_dawa",
label: "Dire Dawa",
note: "Inside country",
order: 6,
},
{
value: "outside_ali_sabieh",
label: "Ali Sabieh",
note: "Outside country",
order: 7,
},
{
value: "outside_holhol",
label: "Holhol",
note: "Outside country",
order: 8,
},
{
value: "outside_djibouti_city",
label: "Djibouti City",
note: "Outside country",
order: 9,
},
{
value: "outside_doraleh_terminal",
label: "Doraleh Terminal",
note: "Outside country",
order: 10,
},
];
@Injectable()
export class DropdownSettingsService {
constructor(
@@ -62,6 +127,34 @@ export class DropdownSettingsService {
return this.getById(setting.id);
}
async seedDefaultStations(): Promise<void> {
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
if (!existing) {
await this.create({
code: STATIONS_TER_CODE,
label: "Stations TER",
description:
"Temporary freight station list used by booking origin and destination yards.",
multiple: false,
meta: {
searchable: true,
clearable: true,
version: "temporary",
},
children: DEFAULT_STATION_OPTIONS,
});
return;
}
if ((existing.children?.length ?? 0) === 0) {
await this.repository.replaceOptions(
existing.id,
DEFAULT_STATION_OPTIONS,
);
}
}
async update(
id: string,
dto: UpdateDropdownSettingDto,

View File

@@ -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, {

View File

@@ -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 })

View File

@@ -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`)

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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<FileRecord> {
constructor(
@InjectRepository(FileRecord)
repository: Repository<FileRecord>,
) {
super(repository);
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.repository.find({ where: { resourceId, resource } });
}
findByCode(
resourceId: string,
resource: string,
code: string,
): Promise<FileRecord | null> {
return this.repository.findOne({ where: { resourceId, resource, code } });
}
}

View File

@@ -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<FileRecord> {
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<FileRecord[]> {
return Promise.all(
files.map((file) =>
this.upload({ resourceId, resource, code: file.fieldname, file }),
),
);
}
async findById(id: string): Promise<FileRecord> {
const record = await this.filesRepository.findById(id);
if (!record) throw new NotFoundException(`File ${id} not found`);
return record;
}
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
return this.filesRepository.findByResource(resourceId, resource);
}
async findByCode(
resourceId: string,
resource: string,
code: string,
): Promise<FileRecord> {
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("/");
}
}

View File

@@ -0,0 +1,2 @@
export * from "./minio.module";
export * from "./minio.service";

View File

@@ -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",
}));

View File

@@ -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 {}

View File

@@ -0,0 +1,75 @@
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()
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<typeof minioConfig>,
) {
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<string> {
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<void> {
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;
}
}
async getFileStream(objectName: string): Promise<Readable> {
try {
return this.client.getObject(this.bucket, objectName);
} catch (error) {
this.logger.error(`Failed to get file ${objectName}:`, error);
throw error;
}
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}