Project Initialization

This commit is contained in:
Muluhabt
2026-05-12 15:17:16 +03:00
parent 33fa742e8a
commit 3b8b6979db
259 changed files with 15962 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
# App
NODE_ENV=development
PORT=3001
# Database
DB_HOST=localhost
DB_PORT=5433
DB_NAME=edr_freight
DB_USER=postgres
DB_PASSWORD=
# JWT (provided by external auth package — placeholder only)
JWT_SECRET=
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/freight-api...
FROM deps AS build
COPY apps/edr-freight-api ./apps/edr-freight-api
RUN pnpm --filter @edr/freight-api build
FROM node:20-alpine AS runtime
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app/apps/edr-freight-api
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./../../node_modules
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules
COPY --from=build /app/apps/edr-freight-api/dist ./dist
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json
EXPOSE 3001
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@@ -0,0 +1,58 @@
{
"name": "@edr/freight-api",
"version": "0.0.0",
"private": true,
"description": "EDR Freight Management API",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main.js",
"lint": "eslint src",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/common": "^10.4.6",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.6",
"@nestjs/platform-express": "^10.4.6",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.2.2",
"@nestjs/testing": "^10.4.6",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/node": "^20.14.0",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import appConfig from './config/app.config';
import databaseConfig from './config/database.config';
import { BookingsModule } from './modules/bookings/bookings.module';
import { ConsignmentsModule } from './modules/consignments/consignments.module';
import { TrainsModule } from './modules/trains/trains.module';
import { CustomersModule } from './modules/customers/customers.module';
import { TrackingModule } from './modules/tracking/tracking.module';
import { BillingModule } from './modules/billing/billing.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig],
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>('database')!,
}),
BookingsModule,
ConsignmentsModule,
TrainsModule,
CustomersModule,
TrackingModule,
BillingModule,
NotificationsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1 @@
export { HttpExceptionFilter } from '@edr/api-common';

View File

@@ -0,0 +1 @@
export { ResponseTransformInterceptor } from '@edr/api-common';

View File

@@ -0,0 +1 @@
export { createValidationPipe } from '@edr/api-common';

View File

@@ -0,0 +1,7 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
env: process.env.NODE_ENV ?? 'development',
port: parseInt(process.env.PORT ?? '3001', 10),
apiPrefix: 'api',
}));

View File

@@ -0,0 +1,16 @@
import { registerAs } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export default registerAs('database', (): TypeOrmModuleOptions => ({
type: 'postgres',
host: process.env.DB_HOST ?? 'localhost',
port: parseInt(process.env.DB_PORT ?? '5433', 10),
username: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'edr_freight',
entities: [__dirname + '/../**/*.entity.{ts,js}'],
migrations: [__dirname + '/../../migrations/*.{ts,js}'],
// Never enable synchronize in production. Use migrations.
synchronize: process.env.NODE_ENV === 'development',
logging: process.env.NODE_ENV === 'development',
}));

View File

@@ -0,0 +1,31 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { HttpExceptionFilter, ResponseTransformInterceptor, createValidationPipe } from '@edr/api-common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
app.setGlobalPrefix('api');
app.useGlobalPipes(createValidationPipe());
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
const config = new DocumentBuilder()
.setTitle('EDR Freight API')
.setDescription('API for the EDR Freight Management application')
.setVersion('0.1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
const port = parseInt(process.env.PORT ?? '3001', 10);
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`[freight-api] listening on http://localhost:${port}`);
}
bootstrap();

View File

@@ -0,0 +1,23 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BillingService } from './billing.service';
@ApiTags('billing')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('billing')
export class BillingController {
constructor(private readonly billingService: BillingService) {}
@Get('invoices')
@ApiOperation({ summary: 'List all invoices' })
findAll() {
return this.billingService.findAll();
}
@Get('invoices/booking/:bookingId')
@ApiOperation({ summary: 'List invoices for a booking' })
findByBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.billingService.findByBooking(bookingId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
import { Invoice } from './entities/invoice.entity';
@Module({
imports: [TypeOrmModule.forFeature([Invoice])],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Invoice } from './entities/invoice.entity';
@Injectable()
export class BillingService {
constructor(
@InjectRepository(Invoice)
private readonly invoicesRepository: Repository<Invoice>,
) {}
/** List every invoice (most recent first). */
findAll(): Promise<Invoice[]> {
return this.invoicesRepository.find({ order: { issuedAt: 'DESC' } });
}
/** List invoices for a given booking. */
findByBooking(bookingId: string): Promise<Invoice[]> {
return this.invoicesRepository.find({ where: { bookingId }, order: { issuedAt: 'DESC' } });
}
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'invoices' })
export class Invoice extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'invoice_number', type: 'varchar', length: 64, unique: true })
invoiceNumber!: string;
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 })
amount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'ETB' })
currency!: string;
@Column({
name: 'status',
type: 'enum',
enum: Freight.PaymentStatus,
default: Freight.PaymentStatus.Pending,
})
status!: Freight.PaymentStatus;
@Column({ name: 'issued_at', type: 'timestamptz' })
issuedAt!: Date;
@Column({ name: 'due_at', type: 'timestamptz' })
dueAt!: Date;
}

View File

@@ -0,0 +1,38 @@
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { CreateBookingDto } from './dto/create-booking.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
@ApiTags('bookings')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('bookings')
export class BookingsController {
constructor(private readonly bookingsService: BookingsService) {}
@Post()
@ApiOperation({ summary: 'Create a new freight booking' })
create(@Body() dto: CreateBookingDto) {
return this.bookingsService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get a freight booking by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.findById(id);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete a freight booking' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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])],
controllers: [BookingsController],
providers: [BookingsService, BookingsRepository],
exports: [BookingsService],
})
export class BookingsModule {}

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 { Booking } from './entities/booking.entity';
@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
constructor(
@InjectRepository(Booking)
repository: Repository<Booking>,
) {
super(repository);
}
/** Find a booking by its human-readable reference number. */
findByReference(reference: string): Promise<Booking | null> {
return this.repository.findOne({ where: { reference } });
}
}

View File

@@ -0,0 +1,50 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BookingsRepository } from './bookings.repository';
import { CreateBookingDto } from './dto/create-booking.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { Booking } from './entities/booking.entity';
@Injectable()
export class BookingsService {
constructor(private readonly bookingsRepository: BookingsRepository) {}
/** Create a new freight booking. */
async create(dto: CreateBookingDto): Promise<Booking> {
return this.bookingsRepository.create({
...dto,
scheduledDate: new Date(dto.scheduledDate),
});
}
/** Return a paginated list of bookings matching the filter. */
async findAll(filter: FilterBookingDto): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const [items, total] = await this.bookingsRepository.findAndCount({
where: {
...(filter.status ? { status: filter.status } : {}),
...(filter.customerId ? { customerId: filter.customerId } : {}),
},
skip: (page - 1) * pageSize,
take: pageSize,
order: { createdAt: 'DESC' },
});
return { items, total };
}
/** Get a single booking by ID, throwing if not found. */
async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) {
throw new NotFoundException(`Booking ${id} not found`);
}
return booking;
}
/** Soft-delete a booking. */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.bookingsRepository.softDelete(id);
}
}

View File

@@ -0,0 +1,33 @@
import { Freight } from '@edr/types';
import {
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
} from 'class-validator';
export class CreateBookingDto {
@IsString()
reference!: string;
@IsUUID()
customerId!: string;
@IsOptional()
@IsUUID()
trainId?: string;
@IsDateString()
scheduledDate!: string;
@IsNumber()
@Min(0)
totalAmount!: number;
@IsOptional()
@IsEnum(Freight.BookingStatus)
status?: Freight.BookingStatus;
}

View File

@@ -0,0 +1,25 @@
import { Freight } from '@edr/types';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
export class FilterBookingDto {
@IsOptional()
@IsEnum(Freight.BookingStatus)
status?: Freight.BookingStatus;
@IsOptional()
@IsUUID()
customerId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'bookings' })
export class Booking extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
@Column({ name: 'customer_id', type: 'uuid' })
customerId!: string;
@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: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
@Column({
name: 'payment_status',
type: 'enum',
enum: Freight.PaymentStatus,
default: Freight.PaymentStatus.Pending,
})
paymentStatus!: Freight.PaymentStatus;
}

View File

@@ -0,0 +1,31 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ConsignmentsService } from './consignments.service';
import { CreateConsignmentDto } from './dto/create-consignment.dto';
import { FilterConsignmentDto } from './dto/filter-consignment.dto';
@ApiTags('consignments')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('consignments')
export class ConsignmentsController {
constructor(private readonly consignmentsService: ConsignmentsService) {}
@Post()
@ApiOperation({ summary: 'Create a new consignment' })
create(@Body() dto: CreateConsignmentDto) {
return this.consignmentsService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List consignments (paginated)' })
findAll(@Query() filter: FilterConsignmentDto) {
return this.consignmentsService.findAll(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get a consignment by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.consignmentsService.findById(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConsignmentsController } from './consignments.controller';
import { ConsignmentsRepository } from './consignments.repository';
import { ConsignmentsService } from './consignments.service';
import { Consignment } from './entities/consignment.entity';
@Module({
imports: [TypeOrmModule.forFeature([Consignment])],
controllers: [ConsignmentsController],
providers: [ConsignmentsService, ConsignmentsRepository],
exports: [ConsignmentsService],
})
export class ConsignmentsModule {}

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 { Consignment } from './entities/consignment.entity';
@Injectable()
export class ConsignmentsRepository extends BaseRepository<Consignment> {
constructor(
@InjectRepository(Consignment)
repository: Repository<Consignment>,
) {
super(repository);
}
/** Look up a consignment by its tracking number. */
findByTrackingNumber(trackingNumber: string): Promise<Consignment | null> {
return this.repository.findOne({ where: { trackingNumber } });
}
}

View File

@@ -0,0 +1,41 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConsignmentsRepository } from './consignments.repository';
import { CreateConsignmentDto } from './dto/create-consignment.dto';
import { FilterConsignmentDto } from './dto/filter-consignment.dto';
import { Consignment } from './entities/consignment.entity';
@Injectable()
export class ConsignmentsService {
constructor(private readonly consignmentsRepository: ConsignmentsRepository) {}
/** Create a new consignment for a freight booking. */
create(dto: CreateConsignmentDto): Promise<Consignment> {
return this.consignmentsRepository.create(dto);
}
/** Return a paginated list of consignments. */
async findAll(filter: FilterConsignmentDto): Promise<{ items: Consignment[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const [items, total] = await this.consignmentsRepository.findAndCount({
where: {
...(filter.status ? { status: filter.status } : {}),
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
},
skip: (page - 1) * pageSize,
take: pageSize,
order: { createdAt: 'DESC' },
});
return { items, total };
}
/** Get a single consignment by ID. */
async findById(id: string): Promise<Consignment> {
const consignment = await this.consignmentsRepository.findById(id);
if (!consignment) {
throw new NotFoundException(`Consignment ${id} not found`);
}
return consignment;
}
}

View File

@@ -0,0 +1,23 @@
import { Freight } from '@edr/types';
import { IsEnum, IsNumber, IsString, IsUUID, Min } from 'class-validator';
export class CreateConsignmentDto {
@IsUUID()
bookingId!: string;
@IsString()
trackingNumber!: string;
@IsEnum(Freight.CargoType)
cargoType!: Freight.CargoType;
@IsNumber()
@Min(0)
weightKg!: number;
@IsString()
originStation!: string;
@IsString()
destinationStation!: string;
}

View File

@@ -0,0 +1,25 @@
import { Freight } from '@edr/types';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
export class FilterConsignmentDto {
@IsOptional()
@IsEnum(Freight.ConsignmentStatus)
status?: Freight.ConsignmentStatus;
@IsOptional()
@IsUUID()
bookingId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'consignments' })
export class Consignment extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'tracking_number', type: 'varchar', length: 64, unique: true })
trackingNumber!: string;
@Column({ name: 'cargo_type', type: 'enum', enum: Freight.CargoType })
cargoType!: Freight.CargoType;
@Column({ name: 'weight_kg', type: 'numeric', precision: 12, scale: 2 })
weightKg!: number;
@Column({
name: 'status',
type: 'enum',
enum: Freight.ConsignmentStatus,
default: Freight.ConsignmentStatus.Pending,
})
status!: Freight.ConsignmentStatus;
@Column({ name: 'origin_station', type: 'varchar', length: 128 })
originStation!: string;
@Column({ name: 'destination_station', type: 'varchar', length: 128 })
destinationStation!: string;
}

View File

@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CustomersService } from './customers.service';
import { CreateCustomerDto } from './dto/create-customer.dto';
@ApiTags('customers')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@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);
}
}

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,29 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CustomersRepository } from './customers.repository';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { Customer } from './entities/customer.entity';
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
/** Create a new freight customer. */
create(dto: CreateCustomerDto): Promise<Customer> {
return this.customersRepository.create(dto);
}
/** List every customer (alphabetical). */
findAll(): Promise<Customer[]> {
return this.customersRepository.findAll({ order: { name: 'ASC' } });
}
/** Get a single 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`);
}
return customer;
}
}

View File

@@ -0,0 +1,20 @@
import { IsEmail, IsOptional, IsString } from 'class-validator';
export class CreateCustomerDto {
@IsString()
name!: string;
@IsEmail()
email!: string;
@IsString()
phone!: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
taxId?: string;
}

View File

@@ -0,0 +1,20 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity } from 'typeorm';
@Entity({ 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: 'address', type: 'text', nullable: true })
address?: string | null;
@Column({ name: 'tax_id', type: 'varchar', length: 64, nullable: true })
taxId?: string | null;
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
@Module({
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
/**
* Dispatch a notification to an operator or customer.
* TODO: wire to email/SMS provider (SendGrid, SMS API, etc.) via a mailer service.
*/
async send(recipient: string, subject: string, body: string): Promise<void> {
this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`);
}
}

View File

@@ -0,0 +1,21 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'tracking_events' })
export class TrackingEvent extends BaseEntity {
@Column({ name: 'consignment_id', type: 'uuid' })
consignmentId!: string;
@Column({ name: 'location', type: 'varchar', length: 256 })
location!: string;
@Column({ name: 'status', type: 'enum', enum: Freight.ConsignmentStatus })
status!: Freight.ConsignmentStatus;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'description', type: 'text', nullable: true })
description?: string | null;
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrackingService } from './tracking.service';
@ApiTags('tracking')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('tracking')
export class TrackingController {
constructor(private readonly trackingService: TrackingService) {}
@Get(':consignmentId')
@ApiOperation({ summary: 'Get the tracking timeline for a consignment' })
findByConsignment(@Param('consignmentId', ParseUUIDPipe) consignmentId: string) {
return this.trackingService.findByConsignment(consignmentId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrackingEvent } from './entities/tracking-event.entity';
import { TrackingController } from './tracking.controller';
import { TrackingService } from './tracking.service';
@Module({
imports: [TypeOrmModule.forFeature([TrackingEvent])],
controllers: [TrackingController],
providers: [TrackingService],
exports: [TrackingService],
})
export class TrackingModule {}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrackingEvent } from './entities/tracking-event.entity';
@Injectable()
export class TrackingService {
constructor(
@InjectRepository(TrackingEvent)
private readonly trackingRepository: Repository<TrackingEvent>,
) {}
/** Get the full timeline of tracking events for a consignment. */
findByConsignment(consignmentId: string): Promise<TrackingEvent[]> {
return this.trackingRepository.find({
where: { consignmentId },
order: { occurredAt: 'ASC' },
});
}
/** Record a new tracking event for a consignment. */
record(event: Partial<TrackingEvent>): Promise<TrackingEvent> {
const entity = this.trackingRepository.create(event);
return this.trackingRepository.save(entity);
}
}

View File

@@ -0,0 +1,19 @@
import { Freight } from '@edr/types';
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class CreateTrainDto {
@IsString()
code!: string;
@IsNumber()
@Min(0)
capacityTons!: number;
@IsOptional()
@IsEnum(Freight.TrainStatus)
status?: Freight.TrainStatus;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Freight } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'trains' })
export class Train extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 2 })
capacityTons!: number;
@Column({
name: 'status',
type: 'enum',
enum: Freight.TrainStatus,
default: Freight.TrainStatus.Available,
})
status!: Freight.TrainStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,30 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateTrainDto } from './dto/create-train.dto';
import { TrainsService } from './trains.service';
@ApiTags('trains')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('trains')
export class TrainsController {
constructor(private readonly trainsService: TrainsService) {}
@Post()
@ApiOperation({ summary: 'Register a new train' })
create(@Body() dto: CreateTrainDto) {
return this.trainsService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List all trains' })
findAll() {
return this.trainsService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get a train by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.trainsService.findById(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Train } from './entities/train.entity';
import { TrainsController } from './trains.controller';
import { TrainsRepository } from './trains.repository';
import { TrainsService } from './trains.service';
@Module({
imports: [TypeOrmModule.forFeature([Train])],
controllers: [TrainsController],
providers: [TrainsService, TrainsRepository],
exports: [TrainsService],
})
export class TrainsModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Train } from './entities/train.entity';
@Injectable()
export class TrainsRepository extends BaseRepository<Train> {
constructor(
@InjectRepository(Train)
repository: Repository<Train>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateTrainDto } from './dto/create-train.dto';
import { Train } from './entities/train.entity';
import { TrainsRepository } from './trains.repository';
@Injectable()
export class TrainsService {
constructor(private readonly trainsRepository: TrainsRepository) {}
/** Register a new train in the fleet. */
create(dto: CreateTrainDto): Promise<Train> {
return this.trainsRepository.create(dto);
}
/** List every active train. */
findAll(): Promise<Train[]> {
return this.trainsRepository.findAll({ order: { code: 'ASC' } });
}
/** Get a single train by ID. */
async findById(id: string): Promise<Train> {
const train = await this.trainsRepository.findById(id);
if (!train) {
throw new NotFoundException(`Train ${id} not found`);
}
return train;
}
}

View File

@@ -0,0 +1,26 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Freight API (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('GET /api/bookings returns a 200 with a list', () => {
return request(app.getHttpServer()).get('/api/bookings').expect(200);
});
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*.spec.ts"]
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@edr/tsconfig/nestjs.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist",
"rootDir": "./src",
"noEmit": false,
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo"
},
"include": ["src"]
}