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=3002
# Database
DB_HOST=localhost
DB_PORT=5434
DB_NAME=edr_passenger
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-passenger-api/package.json ./apps/edr-passenger-api/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/passenger-api...
FROM deps AS build
COPY apps/edr-passenger-api ./apps/edr-passenger-api
RUN pnpm --filter @edr/passenger-api build
FROM node:20-alpine AS runtime
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app/apps/edr-passenger-api
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./../../node_modules
COPY --from=deps /app/apps/edr-passenger-api/node_modules ./node_modules
COPY --from=build /app/apps/edr-passenger-api/dist ./dist
COPY --from=build /app/apps/edr-passenger-api/package.json ./package.json
EXPOSE 3002
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/passenger-api",
"version": "0.0.0",
"private": true,
"description": "EDR Passenger 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 { TicketsModule } from './modules/tickets/tickets.module';
import { SchedulesModule } from './modules/schedules/schedules.module';
import { PassengersModule } from './modules/passengers/passengers.module';
import { SeatsModule } from './modules/seats/seats.module';
import { StationsModule } from './modules/stations/stations.module';
import { PaymentsModule } from './modules/payments/payments.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')!,
}),
TicketsModule,
SchedulesModule,
PassengersModule,
SeatsModule,
StationsModule,
PaymentsModule,
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 ?? '3002', 10),
apiPrefix: 'api',
}));

View File

@@ -0,0 +1,15 @@
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 ?? '5434', 10),
username: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'edr_passenger',
entities: [__dirname + '/../**/*.entity.{ts,js}'],
migrations: [__dirname + '/../../migrations/*.{ts,js}'],
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 Passenger API')
.setDescription('API for the EDR Passenger 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 ?? '3002', 10);
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`[passenger-api] listening on http://localhost:${port}`);
}
bootstrap();

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 a passenger (booking confirmation, schedule change, etc.).
* TODO: wire to email/SMS provider 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,20 @@
import { IsDateString, IsEmail, IsOptional, IsString } from 'class-validator';
export class CreatePassengerDto {
@IsString()
fullName!: string;
@IsEmail()
email!: string;
@IsString()
phone!: string;
@IsOptional()
@IsString()
nationalId?: string;
@IsOptional()
@IsDateString()
dateOfBirth?: string;
}

View File

@@ -0,0 +1,20 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'passengers' })
export class Passenger extends BaseEntity {
@Column({ name: 'full_name', type: 'varchar', length: 256 })
fullName!: string;
@Column({ name: 'email', type: 'varchar', length: 256, unique: true })
email!: string;
@Column({ name: 'phone', type: 'varchar', length: 32 })
phone!: string;
@Column({ name: 'national_id', type: 'varchar', length: 64, nullable: true })
nationalId?: string | null;
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
dateOfBirth?: 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 { CreatePassengerDto } from './dto/create-passenger.dto';
import { PassengersService } from './passengers.service';
@ApiTags('passengers')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('passengers')
export class PassengersController {
constructor(private readonly passengersService: PassengersService) {}
@Post()
@ApiOperation({ summary: 'Register a new passenger' })
create(@Body() dto: CreatePassengerDto) {
return this.passengersService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List all passengers' })
findAll() {
return this.passengersService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get a passenger by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.passengersService.findById(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Passenger } from './entities/passenger.entity';
import { PassengersController } from './passengers.controller';
import { PassengersRepository } from './passengers.repository';
import { PassengersService } from './passengers.service';
@Module({
imports: [TypeOrmModule.forFeature([Passenger])],
controllers: [PassengersController],
providers: [PassengersService, PassengersRepository],
exports: [PassengersService],
})
export class PassengersModule {}

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 { Passenger } from './entities/passenger.entity';
@Injectable()
export class PassengersRepository extends BaseRepository<Passenger> {
constructor(
@InjectRepository(Passenger)
repository: Repository<Passenger>,
) {
super(repository);
}
/** Find a passenger by their unique email. */
findByEmail(email: string): Promise<Passenger | null> {
return this.repository.findOne({ where: { email } });
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreatePassengerDto } from './dto/create-passenger.dto';
import { Passenger } from './entities/passenger.entity';
import { PassengersRepository } from './passengers.repository';
@Injectable()
export class PassengersService {
constructor(private readonly passengersRepository: PassengersRepository) {}
/** Register a new passenger. */
create(dto: CreatePassengerDto): Promise<Passenger> {
return this.passengersRepository.create(dto);
}
/** List every passenger (alphabetical). */
findAll(): Promise<Passenger[]> {
return this.passengersRepository.findAll({ order: { fullName: 'ASC' } });
}
/** Get a single passenger by ID. */
async findById(id: string): Promise<Passenger> {
const passenger = await this.passengersRepository.findById(id);
if (!passenger) {
throw new NotFoundException(`Passenger ${id} not found`);
}
return passenger;
}
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Passenger } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'payments' })
export class Payment extends BaseEntity {
@Column({ name: 'ticket_id', type: 'uuid' })
ticketId!: string;
@Column({ name: 'amount', type: 'numeric', precision: 10, scale: 2 })
amount!: number;
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'ETB' })
currency!: string;
@Column({
name: 'status',
type: 'enum',
enum: Passenger.PaymentStatus,
default: Passenger.PaymentStatus.Pending,
})
status!: Passenger.PaymentStatus;
@Column({ name: 'provider', type: 'varchar', length: 64 })
provider!: string;
@Column({ name: 'provider_transaction_id', type: 'varchar', length: 256, nullable: true })
providerTransactionId?: string | null;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt?: Date | null;
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
@ApiTags('payments')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Get('ticket/:ticketId')
@ApiOperation({ summary: 'List payments for a ticket' })
findByTicket(@Param('ticketId', ParseUUIDPipe) ticketId: string) {
return this.paymentsService.findByTicket(ticketId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Payment } from './entities/payment.entity';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
@Module({
imports: [TypeOrmModule.forFeature([Payment])],
controllers: [PaymentsController],
providers: [PaymentsService],
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Payment } from './entities/payment.entity';
@Injectable()
export class PaymentsService {
constructor(
@InjectRepository(Payment)
private readonly paymentsRepository: Repository<Payment>,
) {}
/** List payments associated with a ticket. */
findByTicket(ticketId: string): Promise<Payment[]> {
return this.paymentsRepository.find({
where: { ticketId },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -0,0 +1,27 @@
import { Passenger } from '@edr/types';
import { IsDateString, IsEnum, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
export class CreateScheduleDto {
@IsString()
trainCode!: string;
@IsUUID()
originStationId!: string;
@IsUUID()
destinationStationId!: string;
@IsDateString()
departureTime!: string;
@IsDateString()
arrivalTime!: string;
@IsNumber()
@Min(0)
basePrice!: number;
@IsOptional()
@IsEnum(Passenger.ScheduleStatus)
status?: Passenger.ScheduleStatus;
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Passenger } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'schedules' })
export class Schedule extends BaseEntity {
@Column({ name: 'train_code', type: 'varchar', length: 32 })
trainCode!: string;
@Column({ name: 'origin_station_id', type: 'uuid' })
originStationId!: string;
@Column({ name: 'destination_station_id', type: 'uuid' })
destinationStationId!: string;
@Column({ name: 'departure_time', type: 'timestamptz' })
departureTime!: Date;
@Column({ name: 'arrival_time', type: 'timestamptz' })
arrivalTime!: Date;
@Column({
name: 'status',
type: 'enum',
enum: Passenger.ScheduleStatus,
default: Passenger.ScheduleStatus.Scheduled,
})
status!: Passenger.ScheduleStatus;
@Column({ name: 'base_price', type: 'numeric', precision: 10, scale: 2 })
basePrice!: number;
}

View File

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

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Schedule } from './entities/schedule.entity';
import { SchedulesController } from './schedules.controller';
import { SchedulesRepository } from './schedules.repository';
import { SchedulesService } from './schedules.service';
@Module({
imports: [TypeOrmModule.forFeature([Schedule])],
controllers: [SchedulesController],
providers: [SchedulesService, SchedulesRepository],
exports: [SchedulesService],
})
export class SchedulesModule {}

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 { Schedule } from './entities/schedule.entity';
@Injectable()
export class SchedulesRepository extends BaseRepository<Schedule> {
constructor(
@InjectRepository(Schedule)
repository: Repository<Schedule>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,33 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateScheduleDto } from './dto/create-schedule.dto';
import { Schedule } from './entities/schedule.entity';
import { SchedulesRepository } from './schedules.repository';
@Injectable()
export class SchedulesService {
constructor(private readonly schedulesRepository: SchedulesRepository) {}
/** Publish a new train schedule. */
create(dto: CreateScheduleDto): Promise<Schedule> {
return this.schedulesRepository.create({
...dto,
departureTime: new Date(dto.departureTime),
arrivalTime: new Date(dto.arrivalTime),
});
}
/** List every published schedule. */
findAll(): Promise<Schedule[]> {
return this.schedulesRepository.findAll({ order: { departureTime: 'ASC' } });
}
/** Get a single schedule by ID. */
async findById(id: string): Promise<Schedule> {
const schedule = await this.schedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Schedule ${id} not found`);
}
return schedule;
}
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Passenger } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'seats' })
export class Seat extends BaseEntity {
@Column({ name: 'schedule_id', type: 'uuid' })
scheduleId!: string;
@Column({ name: 'seat_number', type: 'varchar', length: 16 })
seatNumber!: string;
@Column({ name: 'seat_class', type: 'enum', enum: Passenger.SeatClass })
seatClass!: Passenger.SeatClass;
@Column({
name: 'status',
type: 'enum',
enum: Passenger.SeatStatus,
default: Passenger.SeatStatus.Available,
})
status!: Passenger.SeatStatus;
@Column({ name: 'price', type: 'numeric', precision: 10, scale: 2 })
price!: number;
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { SeatsService } from './seats.service';
@ApiTags('seats')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('seats')
export class SeatsController {
constructor(private readonly seatsService: SeatsService) {}
@Get('schedule/:scheduleId')
@ApiOperation({ summary: 'List seats for a schedule' })
findBySchedule(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.seatsService.findBySchedule(scheduleId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Seat } from './entities/seat.entity';
import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
@Module({
imports: [TypeOrmModule.forFeature([Seat])],
controllers: [SeatsController],
providers: [SeatsService],
exports: [SeatsService],
})
export class SeatsModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Seat } from './entities/seat.entity';
@Injectable()
export class SeatsService {
constructor(
@InjectRepository(Seat)
private readonly seatsRepository: Repository<Seat>,
) {}
/** List every seat on a given schedule, ordered by seat number. */
findBySchedule(scheduleId: string): Promise<Seat[]> {
return this.seatsRepository.find({
where: { scheduleId },
order: { seatNumber: 'ASC' },
});
}
}

View File

@@ -0,0 +1,23 @@
import { IsNumber, IsOptional, IsString } from 'class-validator';
export class CreateStationDto {
@IsString()
code!: string;
@IsString()
name!: string;
@IsString()
city!: string;
@IsString()
country!: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'stations' })
export class Station extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 16, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 128 })
name!: string;
@Column({ name: 'city', type: 'varchar', length: 128 })
city!: string;
@Column({ name: 'country', type: 'varchar', length: 64 })
country!: string;
@Column({ name: 'latitude', type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude?: number | null;
@Column({ name: 'longitude', type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude?: number | null;
}

View File

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

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Station } from './entities/station.entity';
import { StationsController } from './stations.controller';
import { StationsService } from './stations.service';
@Module({
imports: [TypeOrmModule.forFeature([Station])],
controllers: [StationsController],
providers: [StationsService],
exports: [StationsService],
})
export class StationsModule {}

View File

@@ -0,0 +1,34 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateStationDto } from './dto/create-station.dto';
import { Station } from './entities/station.entity';
@Injectable()
export class StationsService {
constructor(
@InjectRepository(Station)
private readonly stationsRepository: Repository<Station>,
) {}
/** Register a new station. */
create(dto: CreateStationDto): Promise<Station> {
const entity = this.stationsRepository.create(dto);
return this.stationsRepository.save(entity);
}
/** List every station (alphabetical). */
findAll(): Promise<Station[]> {
return this.stationsRepository.find({ order: { name: 'ASC' } });
}
/** Get a single station by ID. */
async findById(id: string): Promise<Station> {
const station = await this.stationsRepository.findOne({ where: { id } });
if (!station) {
throw new NotFoundException(`Station ${id} not found`);
}
return station;
}
}

View File

@@ -0,0 +1,27 @@
import { Passenger } from '@edr/types';
import { IsDateString, IsEnum, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
export class CreateTicketDto {
@IsString()
reference!: string;
@IsUUID()
passengerId!: string;
@IsUUID()
scheduleId!: string;
@IsUUID()
seatId!: string;
@IsNumber()
@Min(0)
pricePaid!: number;
@IsDateString()
issuedAt!: string;
@IsOptional()
@IsEnum(Passenger.TicketStatus)
status?: Passenger.TicketStatus;
}

View File

@@ -0,0 +1,29 @@
import { Passenger } from '@edr/types';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
export class FilterTicketDto {
@IsOptional()
@IsEnum(Passenger.TicketStatus)
status?: Passenger.TicketStatus;
@IsOptional()
@IsUUID()
passengerId?: string;
@IsOptional()
@IsUUID()
scheduleId?: 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 { Passenger } from '@edr/types';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'tickets' })
export class Ticket extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
@Column({ name: 'passenger_id', type: 'uuid' })
passengerId!: string;
@Column({ name: 'schedule_id', type: 'uuid' })
scheduleId!: string;
@Column({ name: 'seat_id', type: 'uuid' })
seatId!: string;
@Column({
name: 'status',
type: 'enum',
enum: Passenger.TicketStatus,
default: Passenger.TicketStatus.Reserved,
})
status!: Passenger.TicketStatus;
@Column({ name: 'price_paid', type: 'numeric', precision: 10, scale: 2 })
pricePaid!: number;
@Column({ name: 'issued_at', type: 'timestamptz' })
issuedAt!: 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 { CreateTicketDto } from './dto/create-ticket.dto';
import { FilterTicketDto } from './dto/filter-ticket.dto';
import { TicketsService } from './tickets.service';
@ApiTags('tickets')
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller('tickets')
export class TicketsController {
constructor(private readonly ticketsService: TicketsService) {}
@Post()
@ApiOperation({ summary: 'Issue a new passenger ticket' })
create(@Body() dto: CreateTicketDto) {
return this.ticketsService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List passenger tickets (paginated)' })
findAll(@Query() filter: FilterTicketDto) {
return this.ticketsService.findAll(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get a ticket by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.ticketsService.findById(id);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Cancel a ticket' })
cancel(@Param('id', ParseUUIDPipe) id: string) {
return this.ticketsService.cancel(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Ticket } from './entities/ticket.entity';
import { TicketsController } from './tickets.controller';
import { TicketsRepository } from './tickets.repository';
import { TicketsService } from './tickets.service';
@Module({
imports: [TypeOrmModule.forFeature([Ticket])],
controllers: [TicketsController],
providers: [TicketsService, TicketsRepository],
exports: [TicketsService],
})
export class TicketsModule {}

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

View File

@@ -0,0 +1,51 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateTicketDto } from './dto/create-ticket.dto';
import { FilterTicketDto } from './dto/filter-ticket.dto';
import { Ticket } from './entities/ticket.entity';
import { TicketsRepository } from './tickets.repository';
@Injectable()
export class TicketsService {
constructor(private readonly ticketsRepository: TicketsRepository) {}
/** Issue a new passenger ticket. */
create(dto: CreateTicketDto): Promise<Ticket> {
return this.ticketsRepository.create({
...dto,
issuedAt: new Date(dto.issuedAt),
});
}
/** Paginated list of tickets matching the filter. */
async findAll(filter: FilterTicketDto): Promise<{ items: Ticket[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const [items, total] = await this.ticketsRepository.findAndCount({
where: {
...(filter.status ? { status: filter.status } : {}),
...(filter.passengerId ? { passengerId: filter.passengerId } : {}),
...(filter.scheduleId ? { scheduleId: filter.scheduleId } : {}),
},
skip: (page - 1) * pageSize,
take: pageSize,
order: { createdAt: 'DESC' },
});
return { items, total };
}
/** Get a single ticket by ID. */
async findById(id: string): Promise<Ticket> {
const ticket = await this.ticketsRepository.findById(id);
if (!ticket) {
throw new NotFoundException(`Ticket ${id} not found`);
}
return ticket;
}
/** Cancel and soft-delete a ticket. */
async cancel(id: string): Promise<void> {
await this.findById(id);
await this.ticketsRepository.softDelete(id);
}
}

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('Passenger 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/tickets returns a 200 with a list', () => {
return request(app.getHttpServer()).get('/api/tickets').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"]
}