Initial commit of edr-passenger-api alpha version

This commit is contained in:
Stephanos A
2026-05-13 16:58:49 +03:00
parent 199a3eba11
commit 39ba561d8f
113 changed files with 3602 additions and 1035 deletions

View File

@@ -1,36 +1,51 @@
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";
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './common/prisma.module';
import appConfig from './config/app.config';
import dbConfig from './config/database.config';
import { AuthModule } from './modules/auth/auth.module';
import { StationsModule } from './modules/stations/stations.module';
import { FleetModule } from './modules/fleet/fleet.module';
import { SchedulesModule } from './modules/schedules/schedules.module';
import { SearchModule } from './modules/search/search.module';
import { SeatsModule } from './modules/seats/seats.module';
import { BookingsModule } from './modules/bookings/bookings.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { TicketsModule } from './modules/tickets/tickets.module';
import { PassengersModule } from './modules/passengers/passengers.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { LoyaltyModule } from './modules/loyalty/loyalty.module';
import { WalletModule } from './modules/wallet/wallet.module';
import { PromosModule } from './modules/promos/promos.module';
import { LiveModule } from './modules/live/live.module';
import { SupportModule } from './modules/support/support.module';
import { DashboardModule } from './modules/dashboard/dashboard.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,
ConfigModule.forRoot({ isGlobal: true, load: [appConfig, dbConfig] }),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
PrismaModule,
AuthModule,
StationsModule,
FleetModule,
SchedulesModule,
SearchModule,
SeatsModule,
BookingsModule,
PaymentsModule,
TicketsModule,
PassengersModule,
NotificationsModule,
LoyaltyModule,
WalletModule,
PromosModule,
LiveModule,
SupportModule,
DashboardModule,
],
})
export class AppModule {}

View File

@@ -1 +1,52 @@
export { HttpExceptionFilter } from "@edr/api-common";
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const messageRaw =
exception instanceof HttpException
? exception.getResponse()
: 'Internal server error';
const message =
typeof messageRaw === 'string'
? messageRaw
: ((messageRaw as { message?: string }).message ?? 'Unexpected error');
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url} -> ${status}`,
(exception as Error)?.stack,
);
} else {
this.logger.warn(`${request.method} ${request.url} -> ${status} ${message}`);
}
response.status(status).json({
success: false,
statusCode: status,
message,
error: exception instanceof Error ? exception.name : 'Error',
timestamp: new Date().toISOString(),
path: request.url,
});
}
}

View File

@@ -1 +1,12 @@
export { ResponseTransformInterceptor } from "@edr/api-common";
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class ResponseTransformInterceptor<T> implements NestInterceptor<T, any> {
intercept(_ctx: ExecutionContext, next: CallHandler<T>): Observable<any> {
return next.handle().pipe(
map((data) => ({ success: true, data, timestamp: new Date().toISOString() })),
);
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.get('JWT_SECRET'),
});
}
async validate(payload: any) {
return { userId: payload.sub, email: payload.email, role: payload.role, passengerId: payload.passengerId };
}
}

View File

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

View File

@@ -0,0 +1,6 @@
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}

View File

@@ -1,7 +1,9 @@
import { registerAs } from "@nestjs/config";
import { registerAs } from '@nestjs/config';
export default registerAs("app", () => ({
env: process.env.NODE_ENV ?? "development",
port: parseInt(process.env.PORT ?? "3002", 10),
apiPrefix: "api",
export default registerAs('app', () => ({
port: parseInt(process.env.PORT ?? '4000', 10),
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
portalUrl: process.env.PORTAL_URL ?? 'http://localhost:3001',
}));

View File

@@ -1,18 +1,5 @@
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { registerAs } from '@nestjs/config';
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",
}),
);
export default registerAs('database', () => ({
url: process.env.DATABASE_URL,
}));

View File

@@ -1,35 +1,62 @@
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";
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { ResponseTransformInterceptor } from './common/interceptors/response-transform.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { cors: true });
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: [
process.env.FRONTEND_URL ?? 'http://localhost:3000',
process.env.PORTAL_URL ?? 'http://localhost:3001',
],
});
app.setGlobalPrefix("api");
app.useGlobalPipes(createValidationPipe());
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
const config = new DocumentBuilder()
.setTitle("EDR Passenger API")
.setDescription("API for the EDR Passenger Management application")
.setVersion("0.1.0")
.addBearerAuth()
.setTitle('EDR Passenger API')
.setDescription(
'Ethio-Djibouti Railway Passenger API — booking lifecycle, seat inventory, payment (Telebirr, CBE Birr, eBirr, Card, Wallet), loyalty, live tracking, and support.',
)
.setVersion('1.0.0')
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' }, 'JWT-auth')
.addTag('Auth', 'Registration and login')
.addTag('Stations', 'Station directory')
.addTag('Fleet', 'Train services and coaches')
.addTag('Schedule', 'Trips and fare rules')
.addTag('Search', 'Trip search and fare quotes')
.addTag('Seats', 'Seat maps and holds')
.addTag('Booking', 'Booking lifecycle')
.addTag('Payment', 'Payment intents and refunds')
.addTag('Tickets', 'QR ticket generation and validation')
.addTag('Passenger', 'Profiles, traveler profiles, saved routes')
.addTag('Notifications', 'Push and email notifications')
.addTag('Loyalty', 'Points, tiers, and rewards')
.addTag('Wallet', 'Wallet balance and ledger')
.addTag('Promotions', 'Promo codes and campaigns')
.addTag('Live Tracking', 'Real-time trip status and crowd signals')
.addTag('Support', 'FAQ and chat support')
.addTag('Dashboard', 'Home dashboard aggregate')
.addServer('http://localhost:4000', 'Development')
.addServer('https://api.edr-platform.com', 'Production')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
SwaggerModule.setup('api-docs', app, document, {
customSiteTitle: 'EDR Passenger API',
swaggerOptions: { persistAuthorization: true, docExpansion: 'none', filter: true },
});
const port = parseInt(process.env.PORT ?? "3002", 10);
const port = process.env.PORT ?? 4000;
await app.listen(port);
// eslint-disable-next-line no-console
console.log(`[passenger-api] listening on http://localhost:${port}`);
console.log(`🚀 EDR Passenger API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
}
bootstrap();

View File

@@ -0,0 +1,18 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto } from './auth.dto';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private service: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register new user' })
register(@Body() dto: RegisterDto) { return this.service.register(dto); }
@Post('login')
@ApiOperation({ summary: 'Login and get JWT' })
login(@Body() dto: LoginDto) { return this.service.login(dto); }
}

View File

@@ -0,0 +1,14 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({ example: 'Kelemu Ketsela' }) @IsString() fullName: string;
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: '+251912345678' }) @IsString() phone: string;
@ApiProperty({ example: 'password123', minLength: 8 }) @IsString() @MinLength(8) password: string;
}
export class LoginDto {
@ApiProperty({ example: 'kelemu@email.com' }) @IsEmail() email: string;
@ApiProperty({ example: 'password123' }) @IsString() password: string;
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from '../../common/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (c: ConfigService) => ({
secret: c.get('JWT_SECRET'),
signOptions: { expiresIn: c.get('JWT_EXPIRES_IN', '7d') },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,42 @@
import { Injectable, UnauthorizedException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../common/prisma.service';
import { RegisterDto, LoginDto } from './auth.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwt: JwtService) {}
async register(dto: RegisterDto) {
const exists = await this.prisma.user.findFirst({
where: { OR: [{ email: dto.email }, { phone: dto.phone }] },
});
if (exists) throw new ConflictException('Email or phone already registered');
const passwordHash = await bcrypt.hash(dto.password, 10);
const user = await this.prisma.user.create({
data: { fullName: dto.fullName, email: dto.email, phone: dto.phone, passwordHash },
});
const passenger = await this.prisma.passenger.create({ data: { userId: user.id } });
await this.prisma.loyaltyAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.walletAccount.create({ data: { passengerId: passenger.id } });
await this.prisma.userPreferences.create({ data: { userId: user.id } });
return this.signToken(user.id, user.email, user.role, passenger.id);
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
include: { passenger: true },
});
if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials');
}
return this.signToken(user.id, user.email, user.role, user.passenger?.id);
}
private signToken(userId: string, email: string, role: string, passengerId?: string) {
const token = this.jwt.sign({ sub: userId, email, role, passengerId });
return { token, user: { id: userId, email, role, passengerId } };
}
}

View File

@@ -0,0 +1,16 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { CreateBookingDto } from './bookings.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Booking')
@Controller('bookings')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class BookingsController {
constructor(private service: BookingsService) {}
@Post() @ApiOperation({ summary: 'Create booking from seat hold' }) create(@Body() dto: CreateBookingDto) { return this.service.create(dto); }
@Get(':bookingRef') @ApiOperation({ summary: 'Get booking by reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
@Delete(':bookingRef')@ApiOperation({ summary: 'Cancel booking' }) cancel(@Param('bookingRef') ref: string) { return this.service.cancel(ref); }
}

View File

@@ -0,0 +1,22 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PassengerInputDto {
@ApiProperty() @IsString() fullName: string;
@ApiProperty() @IsString() phone: string;
@ApiProperty() @IsString() email: string;
@ApiProperty() @IsString() seatId: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
}
export class CreateBookingDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() tripId: string;
@ApiProperty() @IsString() holdId: string;
@ApiProperty({ type: [PassengerInputDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[];
@ApiPropertyOptional({ example: 'ECONOMY', enum: ['ECONOMY', 'BUSINESS', 'FIRST'] }) @IsOptional() @IsString() serviceClass?: string;
@ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number;
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { BookingsController } from './bookings.controller';
import { BookingsService } from './bookings.service';
import { SeatsModule } from '../seats/seats.module';
import { SearchModule } from '../search/search.module';
@Module({ imports: [SeatsModule, SearchModule], controllers: [BookingsController], providers: [BookingsService], exports: [BookingsService] })
export class BookingsModule {}

View File

@@ -0,0 +1,70 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateBookingDto } from './bookings.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
import { SearchService } from '../search/search.service';
function generateRef(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
@Injectable()
export class BookingsService {
constructor(private prisma: PrismaService, private seatsService: SeatsService, private eventEmitter: EventEmitter2, private searchService: SearchService) {}
async create(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId }, include: { originStation: true, destinationStation: true } });
if (!trip) throw new NotFoundException('Trip not found');
const fareQuote = await this.searchService.getFareQuote({ tripId: dto.tripId, serviceClass: dto.serviceClass ?? 'ECONOMY', passengerCount: dto.passengers.length, promoCode: dto.promoCode, loyaltyRedemptionPoints: dto.loyaltyRedemptionPoints });
const booking = await this.prisma.booking.create({
data: { bookingRef: generateRef(), passengerId: dto.passengerId, tripId: dto.tripId, status: 'PENDING_PAYMENT', totalMinor: fareQuote.totalMinor, seats: { create: dto.passengers.map((p) => ({ seatId: p.seatId, passengerName: p.fullName, idDocumentType: p.idDocumentType, idDocumentNumber: p.idDocumentNumber })) } },
include: { seats: { include: { seat: true } }, trip: { include: { originStation: true, destinationStation: true, service: true } } },
});
this.eventEmitter.emit('booking.created', { booking });
return booking;
}
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, paymentIntent: true, ticket: true } });
if (!booking) throw new NotFoundException('Booking not found');
return {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalFare: booking.totalMinor / 100,
createdAt: booking.createdAt,
trip: {
number: booking.trip.service.number,
origin: { id: booking.trip.originStation.id, name: booking.trip.originStation.name, code: booking.trip.originStation.code, city: booking.trip.originStation.city },
destination: { id: booking.trip.destinationStation.id, name: booking.trip.destinationStation.name, code: booking.trip.destinationStation.code, city: booking.trip.destinationStation.city },
departureAt: booking.trip.departureAt,
arrivalAt: booking.trip.arrivalAt,
},
passengers: booking.seats.map((bs) => ({
fullName: bs.passengerName,
seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass },
})),
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
};
}
async cancel(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status === 'CONFIRMED') throw new BadRequestException('Use refund for confirmed bookings');
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
return this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
}
@Cron(CronExpression.EVERY_MINUTE)
async expirePendingBookings() {
const cutoff = new Date(Date.now() - 20 * 60 * 1000);
const expired = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', createdAt: { lt: cutoff } }, include: { seats: true } });
for (const b of expired) { await this.seatsService.releaseSeats(b.seats.map((s) => s.seatId)); await this.prisma.booking.update({ where: { id: b.id }, data: { status: 'CANCELLED' } }); }
}
}

View File

@@ -0,0 +1,14 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Dashboard')
@Controller('dashboard')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class DashboardController {
constructor(private service: DashboardService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get home dashboard aggregate for passenger' })
getHomeDashboard(@Param('passengerId') id: string) { return this.service.getHomeDashboard(id); }
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
@Module({ controllers: [DashboardController], providers: [DashboardService] })
export class DashboardModule {}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) {}
async getHomeDashboard(passengerId: string) {
const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([
this.prisma.passenger.findUnique({ where: { id: passengerId }, include: { user: { select: { fullName: true } }, loyalty: true } }),
this.prisma.booking.findFirst({
where: { passengerId, status: 'CONFIRMED', trip: { departureAt: { gte: now } } },
include: { trip: { include: { originStation: true, destinationStation: true, service: true, liveStatus: true } }, seats: { include: { seat: { include: { coach: true } } }, take: 1 }, ticket: true },
orderBy: { createdAt: 'asc' },
}),
this.prisma.walletAccount.findUnique({ where: { passengerId } }),
this.prisma.promotion.count({ where: { active: true, validUntil: { gte: now } } }),
this.prisma.weatherAlert.findMany({ where: { validUntil: { gte: now } }, take: 3 }),
this.prisma.stationCrowdSignal.findMany({ include: { station: true }, take: 5 }),
this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' }, take: 5 }),
]);
const hour = now.getHours();
const greetingKey = hour < 12 ? 'MORNING' : hour < 17 ? 'AFTERNOON' : 'EVENING';
const firstName = passenger?.user.fullName.split(' ')[0] ?? '';
const seat = upcomingBooking?.seats[0];
return {
user: { firstName, greetingKey },
upcomingTicket: upcomingBooking ? {
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
from: upcomingBooking.trip.originStation.name, to: upcomingBooking.trip.destinationStation.name,
trainName: upcomingBooking.trip.service.name, coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label,
departureAt: upcomingBooking.trip.departureAt,
punctualityLabel: (upcomingBooking.trip.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
} : null,
wallet: wallet ? { balanceMinor: wallet.balanceMinor, currency: wallet.currency } : null,
activePromotionsCount: promos,
weatherAlerts: weatherAlerts.map((w) => ({ id: w.id, title: w.title, message: w.message, severity: w.severity })),
stationSignals: stationSignals.map((s) => ({ stationId: s.stationId, stationName: s.station.name, level: s.level, statusLabel: s.statusLabel })),
savedRoutes: savedRoutes.map((r) => ({ id: r.id, fromName: r.fromName, toName: r.toName, tripCount: r.tripCount })),
};
}
}

View File

@@ -0,0 +1,18 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FleetService } from './fleet.service';
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Fleet')
@Controller('fleet')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class FleetController {
constructor(private service: FleetService) {}
@Get('services') @ApiOperation({ summary: 'List train services' }) getServices() { return this.service.getServices(); }
@Post('services') @ApiOperation({ summary: 'Create train service' }) createService(@Body() dto: CreateTrainServiceDto) { return this.service.createService(dto); }
@Post('coaches') @ApiOperation({ summary: 'Add coach to trip' }) createCoach(@Body() dto: CreateCoachDto) { return this.service.createCoach(dto); }
@Post('seats/batch')@ApiOperation({ summary: 'Batch-create seats for coach' }) createSeatBatch(@Body() dto: CreateSeatBatchDto) { return this.service.createSeatBatch(dto); }
@Get('analytics') @ApiOperation({ summary: 'Fleet analytics' }) getAnalytics() { return this.service.getAnalytics(); }
}

View File

@@ -0,0 +1,20 @@
import { IsString, IsEnum, IsInt } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ServiceClass } from '@prisma/client';
export class CreateTrainServiceDto {
@ApiProperty({ example: '301' }) @IsString() number: string;
@ApiProperty({ example: 'Express 301' }) @IsString() name: string;
}
export class CreateCoachDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ example: 'A' }) @IsString() label: string;
@ApiProperty({ enum: ServiceClass }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
}
export class CreateSeatBatchDto {
@ApiProperty() @IsString() coachId: string;
@ApiProperty({ example: 10 }) @IsInt() rows: number;
@ApiProperty({ example: ['A', 'B', 'C', 'D'] }) cols: string[];
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { FleetController } from './fleet.controller';
import { FleetService } from './fleet.service';
@Module({ controllers: [FleetController], providers: [FleetService], exports: [FleetService] })
export class FleetModule {}

View File

@@ -0,0 +1,26 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTrainServiceDto, CreateCoachDto, CreateSeatBatchDto } from './fleet.dto';
@Injectable()
export class FleetService {
constructor(private prisma: PrismaService) {}
getServices() { return this.prisma.trainService.findMany({ include: { trips: { take: 5, orderBy: { departureAt: 'desc' } } } }); }
createService(dto: CreateTrainServiceDto) { return this.prisma.trainService.create({ data: dto }); }
createCoach(dto: CreateCoachDto) { return this.prisma.coach.create({ data: dto }); }
async createSeatBatch(dto: CreateSeatBatchDto) {
const coach = await this.prisma.coach.findUnique({ where: { id: dto.coachId } });
if (!coach) throw new NotFoundException('Coach not found');
const seats = [];
for (let row = 1; row <= dto.rows; row++) for (const col of dto.cols) seats.push({ coachId: dto.coachId, row, col, label: `${row}${col}` });
await this.prisma.seat.createMany({ data: seats, skipDuplicates: true });
return { created: seats.length };
}
async getAnalytics() {
const [totalServices, totalTrips, totalSeats, bookedSeats] = await Promise.all([
this.prisma.trainService.count(), this.prisma.trip.count(),
this.prisma.seat.count(), this.prisma.seat.count({ where: { status: 'BOOKED' } }),
]);
return { totalServices, totalTrips, totalSeats, bookedSeats, occupancyRate: totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0 };
}
}

View File

@@ -0,0 +1,16 @@
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LiveService } from './live.service';
import { UpdateLiveStatusDto } from './live.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Live Tracking')
@Controller('live')
export class LiveController {
constructor(private service: LiveService) {}
@Get('trips/:tripId') @ApiOperation({ summary: 'Get live status for a trip' }) getTripLiveStatus(@Param('tripId') id: string) { return this.service.getTripLiveStatus(id); }
@Get('trips/:tripId/stops') @ApiOperation({ summary: 'Get stop timeline for a trip' }) getStopTimeline(@Param('tripId') id: string) { return this.service.getStopTimeline(id); }
@Patch('trips/:tripId/status')@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update live trip status (staff/system)' }) updateLiveStatus(@Param('tripId') id: string, @Body() dto: UpdateLiveStatusDto) { return this.service.updateLiveStatus(id, dto); }
@Get('crowd-signals') @ApiOperation({ summary: 'Get station crowd signals' }) getCrowdSignals() { return this.service.getStationCrowdSignals(); }
@Get('weather-alerts') @ApiOperation({ summary: 'Get active weather alerts' }) getWeatherAlerts() { return this.service.getWeatherAlerts(); }
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateLiveStatusDto {
@ApiPropertyOptional({ example: 'EN_ROUTE' }) @IsOptional() @IsString() state?: string;
@ApiPropertyOptional({ example: 'Between Dire Dawa and Dewele' }) @IsOptional() @IsString() currentLocationLabel?: string;
@ApiPropertyOptional({ example: 45 }) @IsOptional() @IsInt() @Min(0) @Max(100) progressPercent?: number;
@ApiPropertyOptional({ example: 10 }) @IsOptional() @IsInt() @Min(0) delayMinutes?: number;
@ApiPropertyOptional({ example: 120 }) @IsOptional() @IsInt() @Min(0) currentSpeedKph?: number;
@ApiPropertyOptional({ example: 'Platform 2' }) @IsOptional() @IsString() platformLabel?: string;
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { LiveController } from './live.controller';
import { LiveService } from './live.service';
@Module({ controllers: [LiveController], providers: [LiveService] })
export class LiveModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class LiveService {
constructor(private prisma: PrismaService) {}
async getTripLiveStatus(tripId: string) {
const trip = await this.prisma.trip.findUnique({
where: { id: tripId },
include: { service: true, originStation: true, destinationStation: true, liveStatus: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } },
});
if (!trip) throw new NotFoundException('Trip not found');
const live = trip.liveStatus;
const nextStop = trip.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
return {
tripId: trip.id, trainName: trip.service.name,
fromStationName: trip.originStation.name, toStationName: trip.destinationStation.name,
state: live?.state ?? trip.status, currentLocationLabel: live?.currentLocationLabel,
progressPercent: live?.progressPercent ?? 0, delayMinutes: live?.delayMinutes ?? 0,
currentSpeedKph: live?.currentSpeedKph, platformLabel: live?.platformLabel,
nextStopStationName: nextStop?.station.name, updatedAt: live?.updatedAt ?? trip.departureAt,
};
}
updateLiveStatus(tripId: string, data: any) {
return this.prisma.tripLiveStatus.upsert({ where: { tripId }, update: data, create: { tripId, state: data.state ?? 'SCHEDULED', ...data } });
}
getStopTimeline(tripId: string) { return this.prisma.tripStopTime.findMany({ where: { tripId }, include: { station: true }, orderBy: { sequence: 'asc' } }); }
getStationCrowdSignals() { return this.prisma.stationCrowdSignal.findMany({ include: { station: true } }); }
getWeatherAlerts() { return this.prisma.weatherAlert.findMany({ where: { validUntil: { gte: new Date() } }, orderBy: { createdAt: 'desc' } }); }
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { LoyaltyService } from './loyalty.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Loyalty')
@Controller('loyalty')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class LoyaltyController {
constructor(private service: LoyaltyService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); }
@Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); }
@Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); }
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { LoyaltyController } from './loyalty.controller';
import { LoyaltyService } from './loyalty.service';
@Module({ controllers: [LoyaltyController], providers: [LoyaltyService] })
export class LoyaltyModule {}

View File

@@ -0,0 +1,43 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class LoyaltyService {
constructor(private prisma: PrismaService) {}
async getAccount(passengerId: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
if (!account) throw new NotFoundException('Loyalty account not found');
const tiers = ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM'];
const thresholds: Record<string, number> = { BRONZE: 0, SILVER: 2000, GOLD: 5000, PLATINUM: 10000 };
const idx = tiers.indexOf(account.tier);
const nextTier = tiers[idx + 1] ?? null;
const nextThreshold = nextTier ? thresholds[nextTier] : null;
return {
...account, nextTier,
points: account.pointsBalance,
nextTierPoints: nextThreshold ?? account.pointsBalance,
pointsToNextTier: nextThreshold ? nextThreshold - account.pointsBalance : 0,
tierProgressPercent: nextThreshold ? +((account.pointsBalance - thresholds[account.tier]) / (nextThreshold - thresholds[account.tier]) * 100).toFixed(2) : 100,
};
}
async getRewards(passengerId: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
if (!account) throw new NotFoundException('Loyalty account not found');
return this.prisma.loyaltyReward.findMany({ where: { accountId: account.id, available: true } });
}
async redeemReward(passengerId: string, rewardId: string) {
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
if (!account) throw new NotFoundException('Loyalty account not found');
const reward = await this.prisma.loyaltyReward.findUnique({ where: { id: rewardId } });
if (!reward?.available) throw new NotFoundException('Reward not available');
if (account.pointsBalance < reward.costPoints) throw new BadRequestException('Insufficient points');
const newBalance = account.pointsBalance - reward.costPoints;
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: newBalance } });
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: -reward.costPoints, reason: 'REWARD_REDEEMED', balanceAfter: newBalance } });
await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } });
return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance };
}
}

View File

@@ -0,0 +1,24 @@
import { Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Notifications')
@Controller('notifications')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class NotificationsController {
constructor(private service: NotificationsService) {}
@Get(':passengerId')
@ApiOperation({ summary: 'Get notifications for passenger' })
getForPassenger(@Param('passengerId') id: string) { return this.service.getForPassenger(id); }
@Patch(':id/read')
@ApiOperation({ summary: 'Mark notification as read' })
markRead(@Param('id') id: string) { return this.service.markRead(id); }
@Patch(':passengerId/read-all')
@ApiOperation({ summary: 'Mark all notifications as read' })
markAllRead(@Param('passengerId') id: string) { return this.service.markAllRead(id); }
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum NotificationCategoryEnum {
BOOKING = 'BOOKING',
PAYMENT = 'PAYMENT',
DISRUPTION = 'DISRUPTION',
PROMOTION = 'PROMOTION',
SYSTEM = 'SYSTEM',
}
export class SendNotificationDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty({ example: 'Platform Change' }) @IsString() title: string;
@ApiProperty({ example: 'Your train departs from Platform 3' }) @IsString() body: string;
@ApiProperty({ enum: NotificationCategoryEnum }) @IsEnum(NotificationCategoryEnum) category: NotificationCategoryEnum;
@ApiPropertyOptional({ example: 'edr://tickets/tkt_01' }) @IsOptional() @IsString() deepLink?: string;
@ApiPropertyOptional() @IsOptional() metadata?: Record<string, any>;
}

View File

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

View File

@@ -1,14 +1,45 @@
import { Injectable, Logger } from "@nestjs/common";
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../common/prisma.service';
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
import * as sgMail from '@sendgrid/mail';
@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}`);
constructor(private prisma: PrismaService) {
if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);
}
}
private sanitize(value: string): string {
return value.replace(/[\r\n]/g, ' ').replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#x27;' }[c] ?? c));
}
async send(dto: SendNotificationDto) {
const notification = await this.prisma.notification.create({ data: { passengerId: dto.passengerId, title: dto.title, body: dto.body, category: dto.category as any, deepLink: dto.deepLink, metadata: dto.metadata } });
const passenger = await this.prisma.passenger.findUnique({ where: { id: dto.passengerId }, include: { user: true } });
if (passenger?.user) await this.sendEmail(passenger.user.email, this.sanitize(dto.title), this.sanitize(dto.body));
return notification;
}
getForPassenger(passengerId: string) { return this.prisma.notification.findMany({ where: { passengerId }, orderBy: { createdAt: 'desc' }, take: 50 }); }
markRead(id: string) { return this.prisma.notification.update({ where: { id }, data: { read: true } }); }
async markAllRead(passengerId: string) { await this.prisma.notification.updateMany({ where: { passengerId, read: false }, data: { read: true } }); return { updated: true }; }
@OnEvent('booking.created')
async onBookingCreated(payload: any) {
await this.send({ passengerId: payload.booking.passengerId, title: 'Booking Created', body: `Booking ${payload.booking.bookingRef} created. Complete payment within 15 minutes.`, category: NotificationCategoryEnum.BOOKING, deepLink: `edr://bookings/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
}
@OnEvent('payment.succeeded')
async onPaymentSucceeded(payload: any) {
await this.send({ passengerId: payload.booking.passengerId, title: 'Payment Successful', body: `Your ticket for ${payload.booking.bookingRef} is confirmed. Have a great journey!`, category: NotificationCategoryEnum.PAYMENT, deepLink: `edr://tickets/${payload.booking.bookingRef}`, metadata: { bookingRef: payload.booking.bookingRef } });
}
private async sendEmail(to: string, subject: string, text: string) {
if (!process.env.SENDGRID_API_KEY) { console.log(`[EMAIL] To: ${to} | Subject: ${subject}`); return; }
try { await sgMail.send({ to, from: process.env.SENDGRID_FROM_EMAIL || 'noreply@edr-platform.com', subject, text }); }
catch (e) { console.error('[EMAIL] Send error:', String(e instanceof Error ? e.message : e).replace(/[\r\n<>&"']/g, ' ')); }
}
}

View File

@@ -1,20 +0,0 @@
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

@@ -1,20 +0,0 @@
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

@@ -1,37 +1,19 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PassengersService } from './passengers.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { CreatePassengerDto } from "./dto/create-passenger.dto";
import { PassengersService } from "./passengers.service";
@ApiTags("passengers")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("passengers")
@ApiTags('Passenger')
@Controller('passengers')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
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);
}
constructor(private service: PassengersService) {}
@Get(':id/profile') @ApiOperation({ summary: 'Get passenger profile' }) getProfile(@Param('id') id: string) { return this.service.getProfile(id); }
@Get(':id/stats') @ApiOperation({ summary: 'Get passenger stats' }) getStats(@Param('id') id: string) { return this.service.getStats(id); }
@Post('traveler-profiles') @ApiOperation({ summary: 'Add traveler profile (family member)' }) createTravelerProfile(@Body() dto: CreateTravelerProfileDto) { return this.service.createTravelerProfile(dto); }
@Get(':id/traveler-profiles') @ApiOperation({ summary: 'Get traveler profiles for passenger' }) getTravelerProfiles(@Param('id') id: string) { return this.service.getTravelerProfiles(id); }
@Post('saved-routes') @ApiOperation({ summary: 'Save a route' }) createSavedRoute(@Body() dto: CreateSavedRouteDto) { return this.service.createSavedRoute(dto); }
@Get(':id/saved-routes') @ApiOperation({ summary: 'Get saved routes' }) getSavedRoutes(@Param('id') id: string) { return this.service.getSavedRoutes(id); }
}

View File

@@ -0,0 +1,19 @@
import { IsString, IsOptional, IsDateString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTravelerProfileDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty({ example: 'Sara Ketsela' }) @IsString() fullName: string;
@ApiProperty({ example: 'SPOUSE' }) @IsString() relationship: string;
@ApiPropertyOptional({ example: '1998-04-01' }) @IsOptional() @IsDateString() dateOfBirth?: string;
@ApiPropertyOptional({ example: 'ET-1234-5678' }) @IsOptional() @IsString() nationalId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
export class CreateSavedRouteDto {
@ApiProperty() @IsString() passengerId: string;
@ApiProperty() @IsString() fromStationId: string;
@ApiProperty() @IsString() toStationId: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() fromName: string;
@ApiProperty({ example: 'Dire Dawa' }) @IsString() toName: string;
}

View File

@@ -1,15 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { PassengersController } from './passengers.controller';
import { PassengersService } from './passengers.service';
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],
})
@Module({ controllers: [PassengersController], providers: [PassengersService] })
export class PassengersModule {}

View File

@@ -1,21 +0,0 @@
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

@@ -1,29 +1,57 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreatePassengerDto } from "./dto/create-passenger.dto";
import { Passenger } from "./entities/passenger.entity";
import { PassengersRepository } from "./passengers.repository";
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTravelerProfileDto, CreateSavedRouteDto } from './passengers.dto';
@Injectable()
export class PassengersService {
constructor(private readonly passengersRepository: PassengersRepository) {}
constructor(private prisma: PrismaService) {}
/** Register a new passenger. */
create(dto: CreatePassengerDto): Promise<Passenger> {
return this.passengersRepository.create(dto);
async getProfile(passengerId: string) {
const p = await this.prisma.passenger.findUnique({
where: { id: passengerId },
include: {
user: { select: { fullName: true, email: true, phone: true } },
bookings: { orderBy: { createdAt: 'desc' }, take: 10, include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } } },
loyalty: true, wallet: true, travelerProfiles: true, savedRoutes: true,
},
});
if (!p) throw new NotFoundException('Passenger not found');
return {
id: p.id,
fullName: p.user.fullName,
email: p.user.email,
phone: p.user.phone,
createdAt: p.createdAt,
bookings: p.bookings.map((b) => ({
id: b.id, bookingRef: b.bookingRef, status: b.status, totalFare: b.totalMinor / 100, createdAt: b.createdAt,
trip: {
number: b.trip.service.number,
origin: { id: b.trip.originStation.id, name: b.trip.originStation.name, code: b.trip.originStation.code, city: b.trip.originStation.city },
destination: { id: b.trip.destinationStation.id, name: b.trip.destinationStation.name, code: b.trip.destinationStation.code, city: b.trip.destinationStation.city },
departureAt: b.trip.departureAt,
},
passengers: b.seats.map((bs) => ({ fullName: bs.passengerName, seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.serviceClass } })),
})),
};
}
/** List every passenger (alphabetical). */
findAll(): Promise<Passenger[]> {
return this.passengersRepository.findAll({ order: { fullName: "ASC" } });
async getStats(passengerId: string) {
const [totalTrips, totalSpendResult, loyalty] = await Promise.all([
this.prisma.booking.count({ where: { passengerId, status: 'COMPLETED' } }),
this.prisma.booking.aggregate({ where: { passengerId, status: 'COMPLETED' }, _sum: { totalMinor: true } }),
this.prisma.loyaltyAccount.findUnique({ where: { passengerId } }),
]);
const totalSpend = (totalSpendResult._sum.totalMinor ?? 0) / 100;
return { totalTrips, totalSpend, loyaltyPoints: loyalty?.pointsBalance ?? 0, co2Saved: totalTrips * 6 };
}
/** 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;
createTravelerProfile(dto: CreateTravelerProfileDto) {
return this.prisma.travelerProfile.create({ data: { ...dto, dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null } });
}
getTravelerProfiles(passengerId: string) { return this.prisma.travelerProfile.findMany({ where: { passengerId } }); }
createSavedRoute(dto: CreateSavedRouteDto) { return this.prisma.savedRoute.create({ data: dto }); }
getSavedRoutes(passengerId: string) { return this.prisma.savedRoute.findMany({ where: { passengerId }, orderBy: { tripCount: 'desc' } }); }
}

View File

@@ -1,37 +0,0 @@
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,10 @@
export interface GatewayResult { success: boolean; providerRef: string; clientAction?: { type: string; url?: string }; }
export async function telebirrAdapter(_a: number, ref: string): Promise<GatewayResult> {
await new Promise((r) => setTimeout(r, 200));
return { success: true, providerRef: `TB-${ref}-${Date.now()}`, clientAction: { type: 'REDIRECT', url: `https://telebirr.sandbox.com/pay/${ref}` } };
}
export async function cbeBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `CBE-${ref}-${Date.now()}` }; }
export async function eBirrAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: true, providerRef: `EB-${ref}-${Date.now()}` }; }
export async function cardAdapter(_a: number, ref: string): Promise<GatewayResult> { await new Promise((r) => setTimeout(r, 150)); return { success: !ref.startsWith('FAIL'), providerRef: `CARD-${ref}-${Date.now()}` }; }
export async function walletAdapter(amount: number, balance: number): Promise<GatewayResult> { return { success: balance >= amount, providerRef: `WALLET-${Date.now()}` }; }

View File

@@ -1,17 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PaymentsService } from "./payments.service";
@ApiTags("payments")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("payments")
@ApiTags('Payment')
@Controller('payments')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
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);
}
constructor(private service: PaymentsService) {}
@Post('initiate') @ApiOperation({ summary: 'Initiate payment for a booking' }) initiatePayment(@Body() dto: InitiatePaymentDto) { return this.service.initiatePayment(dto); }
@Post('refund') @ApiOperation({ summary: 'Refund a confirmed booking' }) refund(@Body() dto: RefundDto) { return this.service.refund(dto); }
@Post('methods') @ApiOperation({ summary: 'Add a payment method' }) addMethod(@Body() dto: AddPaymentMethodDto) { return this.service.addPaymentMethod(dto); }
@Get('methods/:userId') @ApiOperation({ summary: 'Get payment methods for user' }) getMethods(@Param('userId') userId: string) { return this.service.getPaymentMethods(userId); }
}

View File

@@ -0,0 +1,22 @@
import { IsString, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum PaymentMethodTypeEnum { TELEBIRR = 'TELEBIRR', CBE_BIRR = 'CBE_BIRR', EBIRR = 'EBIRR', CARD = 'CARD', WALLET = 'WALLET' }
export class InitiatePaymentDto {
@ApiProperty() @IsString() bookingId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional() @IsOptional() @IsString() paymentMethodId?: string;
}
export class RefundDto {
@ApiProperty() @IsString() bookingId: string;
@ApiPropertyOptional() @IsOptional() @IsString() reason?: string;
}
export class AddPaymentMethodDto {
@ApiProperty() @IsString() userId: string;
@ApiProperty({ enum: PaymentMethodTypeEnum }) @IsEnum(PaymentMethodTypeEnum) type: PaymentMethodTypeEnum;
@ApiProperty() @IsString() displayName: string;
@ApiPropertyOptional() @IsOptional() @IsString() maskedHint?: string;
}

View File

@@ -1,14 +1,8 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { SeatsModule } from '../seats/seats.module';
import { TicketsModule } from '../tickets/tickets.module';
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],
})
@Module({ imports: [SeatsModule, TicketsModule], controllers: [PaymentsController], providers: [PaymentsService] })
export class PaymentsModule {}

View File

@@ -1,21 +1,81 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Payment } from "./entities/payment.entity";
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SeatsService } from '../seats/seats.service';
import { TicketsService } from '../tickets/tickets.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InitiatePaymentDto, RefundDto, AddPaymentMethodDto } from './payments.dto';
import { telebirrAdapter, cbeBirrAdapter, eBirrAdapter, cardAdapter } from './payments.adapters';
@Injectable()
export class PaymentsService {
constructor(
@InjectRepository(Payment)
private readonly paymentsRepository: Repository<Payment>,
private prisma: PrismaService,
private seatsService: SeatsService,
private ticketsService: TicketsService,
private eventEmitter: EventEmitter2,
) {}
/** List payments associated with a ticket. */
findByTicket(ticketId: string): Promise<Payment[]> {
return this.paymentsRepository.find({
where: { ticketId },
order: { createdAt: "DESC" },
async initiatePayment(dto: InitiatePaymentDto) {
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
if (!booking) throw new NotFoundException('Booking not found');
if (booking.status !== 'PENDING_PAYMENT') throw new BadRequestException('Booking not payable');
let result;
if (dto.method === 'WALLET') {
result = await this.prisma.$transaction(async (tx) => {
const wallet = await tx.walletAccount.findUnique({ where: { passengerId: booking.passengerId } });
if (!wallet || wallet.balanceMinor < booking.totalMinor) return { success: false, providerRef: '' };
const newBalance = wallet.balanceMinor - booking.totalMinor;
await tx.walletAccount.update({ where: { passengerId: booking.passengerId }, data: { balanceMinor: newBalance } });
await tx.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'DEBIT', amountMinor: booking.totalMinor, balanceAfterMinor: newBalance, description: `Train Ticket - ${booking.bookingRef}`, relatedBookingId: booking.id } });
return { success: true, providerRef: `WALLET-${Date.now()}` };
});
} else {
const adapters = { TELEBIRR: telebirrAdapter, CBE_BIRR: cbeBirrAdapter, EBIRR: eBirrAdapter, CARD: cardAdapter } as any;
result = await adapters[dto.method](booking.totalMinor, booking.bookingRef);
}
const status = result.success ? 'SUCCEEDED' : 'FAILED';
const intent = await this.prisma.paymentIntent.upsert({
where: { bookingId: dto.bookingId },
update: { status, providerRef: result.providerRef, clientAction: result.clientAction as any },
create: { bookingId: dto.bookingId, amountMinor: booking.totalMinor, method: dto.method as any, status: status as any, providerRef: result.providerRef, clientAction: result.clientAction as any },
});
if (result.success) {
await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CONFIRMED' } });
await this.ticketsService.generate(dto.bookingId);
await this.awardLoyaltyPoints(booking.passengerId, booking.totalMinor, booking.id);
this.eventEmitter.emit('payment.succeeded', { booking });
}
return { id: intent.id, status: result.success ? 'SUCCESS' : 'FAILED', success: result.success };
}
async refund(dto: RefundDto) {
const intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId: dto.bookingId } });
if (!intent || intent.status !== 'SUCCEEDED') throw new BadRequestException('No successful payment to refund');
await this.prisma.paymentIntent.update({ where: { bookingId: dto.bookingId }, data: { status: 'CANCELLED' } });
const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true } });
if (booking) {
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
await this.prisma.booking.update({ where: { id: dto.bookingId }, data: { status: 'CANCELLED' } });
}
return { refunded: true, bookingRef: booking?.bookingRef };
}
addPaymentMethod(dto: AddPaymentMethodDto) { return this.prisma.paymentMethod.create({ data: dto }); }
getPaymentMethods(userId: string) { return this.prisma.paymentMethod.findMany({ where: { userId }, orderBy: { isDefault: 'desc' } }); }
private async awardLoyaltyPoints(passengerId: string, amountMinor: number, bookingId: string) {
const points = Math.floor(amountMinor / 100);
const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId } });
if (!account) return;
const newBalance = account.pointsBalance + points;
const tier = newBalance >= 10000 ? 'PLATINUM' : newBalance >= 5000 ? 'GOLD' : newBalance >= 2000 ? 'SILVER' : 'BRONZE';
await this.prisma.loyaltyAccount.update({ where: { passengerId }, data: { pointsBalance: { increment: points }, tier: tier as any } });
await this.prisma.loyaltyLedgerEntry.create({ data: { accountId: account.id, delta: points, reason: 'TRIP_COMPLETED', bookingId, balanceAfter: newBalance } });
}
}

View File

@@ -0,0 +1,14 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PromosService } from './promos.service';
import { CreatePromotionDto } from './promos.dto';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Promotions')
@Controller('promos')
export class PromosController {
constructor(private service: PromosService) {}
@Get() @ApiOperation({ summary: 'Get active promotions' }) getActive() { return this.service.getActive(); }
@Get('validate/:code') @ApiOperation({ summary: 'Validate a promo code' }) validate(@Param('code') code: string) { return this.service.validate(code); }
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create promotion (admin)' }) create(@Body() dto: CreatePromotionDto) { return this.service.create(dto); }
}

View File

@@ -0,0 +1,13 @@
import { IsString, IsOptional, IsInt } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePromotionDto {
@ApiProperty({ example: 'Weekend Special' }) @IsString() title: string;
@ApiPropertyOptional({ example: '15% off all routes' }) @IsOptional() @IsString() subtitle?: string;
@ApiProperty({ example: 'WEEKEND15' }) @IsString() code: string;
@ApiPropertyOptional({ example: 15 }) @IsOptional() @IsInt() percentOff?: number;
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() amountOffMinor?: number;
@ApiProperty({ example: '2026-12-31T23:59:59Z' }) @IsString() validUntil: string;
@ApiPropertyOptional({ example: 'Book Now' }) @IsOptional() @IsString() ctaLabel?: string;
@ApiPropertyOptional({ example: 'edr://search' }) @IsOptional() @IsString() deepLink?: string;
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { PromosController } from './promos.controller';
import { PromosService } from './promos.service';
@Module({ controllers: [PromosController], providers: [PromosService] })
export class PromosModule {}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreatePromotionDto } from './promos.dto';
@Injectable()
export class PromosService {
constructor(private prisma: PrismaService) {}
getActive() { return this.prisma.promotion.findMany({ where: { active: true, validUntil: { gte: new Date() } }, orderBy: { createdAt: 'desc' } }); }
async validate(code: string) {
const promo = await this.prisma.promotion.findUnique({ where: { code } });
if (!promo || !promo.active || promo.validUntil < new Date()) return { applicable: false, message: 'Promo code invalid or expired' };
return { code: promo.code, percentOff: promo.percentOff, amountOffMinor: promo.amountOffMinor, validUntil: promo.validUntil, applicable: true, message: promo.percentOff ? `${promo.percentOff}% off` : `ETB ${((promo.amountOffMinor ?? 0) / 100).toFixed(2)} off` };
}
create(dto: CreatePromotionDto) {
return this.prisma.promotion.create({ data: { ...dto, validUntil: new Date(dto.validUntil) } });
}
}

View File

@@ -1,35 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -1,37 +1,21 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SchedulesService } from './schedules.service';
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { CreateScheduleDto } from "./dto/create-schedule.dto";
import { SchedulesService } from "./schedules.service";
@ApiTags("schedules")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("schedules")
@ApiTags('Schedule')
@Controller('schedule')
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);
}
constructor(private service: SchedulesService) {}
@Post('trips') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create trip' })
createTrip(@Body() dto: CreateTripDto) { return this.service.createTrip(dto); }
@Get('trips/:id') @ApiOperation({ summary: 'Get trip details' })
getTrip(@Param('id') id: string) { return this.service.getTrip(id); }
@Patch('trips/:id/status') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Update trip status' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateTripStatusDto) { return this.service.updateTripStatus(id, dto); }
@Post('fares') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create fare rule' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Get('fares/:tripId') @ApiOperation({ summary: 'Get fare for trip and class' })
getFare(@Param('tripId') tripId: string, @Query('class') cls: string) { return this.service.getFare(tripId, cls ?? 'ECONOMY'); }
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsDateString, IsInt, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ServiceClass } from '@prisma/client';
export class CreateTripDto {
@ApiProperty() @IsString() serviceId: string;
@ApiProperty() @IsString() originStationId: string;
@ApiProperty() @IsString() destinationStationId: string;
@ApiProperty({ example: '2026-05-11T08:30:00Z' }) @IsDateString() departureAt: string;
@ApiProperty({ example: '2026-05-11T20:00:00Z' }) @IsDateString() arrivalAt: string;
@ApiPropertyOptional() @IsOptional() @IsInt() stopsCount?: number;
}
export class CreateFareRuleDto {
@ApiPropertyOptional() @IsOptional() @IsString() tripId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() route?: string;
@ApiProperty({ enum: ServiceClass }) @IsEnum(ServiceClass) serviceClass: ServiceClass;
@ApiProperty({ example: 45000 }) @IsInt() baseFareMinor: number;
@ApiProperty({ example: '2026-01-01T00:00:00Z' }) @IsDateString() validFrom: string;
@ApiPropertyOptional() @IsOptional() @IsDateString() validUntil?: string;
}
export class UpdateTripStatusDto {
@ApiProperty({ example: 'EN_ROUTE' }) @IsString() status: string;
}

View File

@@ -1,15 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
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],
})
@Module({ controllers: [SchedulesController], providers: [SchedulesService] })
export class SchedulesModule {}

View File

@@ -1,16 +0,0 @@
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

@@ -1,35 +1,39 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreateScheduleDto } from "./dto/create-schedule.dto";
import { Schedule } from "./entities/schedule.entity";
import { SchedulesRepository } from "./schedules.repository";
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateTripDto, CreateFareRuleDto, UpdateTripStatusDto } from './schedules.dto';
@Injectable()
export class SchedulesService {
constructor(private readonly schedulesRepository: SchedulesRepository) {}
constructor(private prisma: PrismaService) {}
/** 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),
async createTrip(dto: CreateTripDto) {
const dep = new Date(dto.departureAt), arr = new Date(dto.arrivalAt);
return this.prisma.trip.create({
data: { serviceId: dto.serviceId, originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: dep, arrivalAt: arr, durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60000), stopsCount: dto.stopsCount ?? 0 },
include: { service: true, originStation: true, destinationStation: true },
});
}
/** List every published schedule. */
findAll(): Promise<Schedule[]> {
return this.schedulesRepository.findAll({
order: { departureTime: "ASC" },
});
async getTrip(id: string) {
const trip = await this.prisma.trip.findUnique({ where: { id }, include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } });
if (!trip) throw new NotFoundException('Trip not found');
return trip;
}
/** 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;
updateTripStatus(id: string, dto: UpdateTripStatusDto) { return this.prisma.trip.update({ where: { id }, data: { status: dto.status as any } }); }
createFareRule(dto: CreateFareRuleDto) {
return this.prisma.fareRule.create({ data: { ...dto, validFrom: new Date(dto.validFrom), validUntil: dto.validUntil ? new Date(dto.validUntil) : null } });
}
async getFare(tripId: string, serviceClass: string) {
const trip = await this.prisma.trip.findUnique({ where: { id: tripId }, include: { originStation: true, destinationStation: true } });
if (!trip) throw new NotFoundException('Trip not found');
const route = `${trip.originStation.code}-${trip.destinationStation.code}`;
const rule = await this.prisma.fareRule.findFirst({
where: { serviceClass: serviceClass as any, validFrom: { lte: new Date() }, OR: [{ tripId }, { route }, { tripId: null, route: null }], AND: [{ OR: [{ validUntil: null }, { validUntil: { gte: new Date() } }] }] },
orderBy: { validFrom: 'desc' },
});
return rule ?? { baseFareMinor: 45000, currency: 'ETB', serviceClass };
}
}

View File

@@ -0,0 +1,12 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { SearchService } from './search.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
@ApiTags('Search')
@Controller('search')
export class SearchController {
constructor(private service: SearchService) {}
@Post() @ApiOperation({ summary: 'Search trips' }) searchTrips(@Body() dto: SearchTripsDto) { return this.service.searchTrips(dto); }
@Post('fare-quote')@ApiOperation({ summary: 'Get fare quote' }) getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); }
}

View File

@@ -0,0 +1,18 @@
import { IsString, IsDateString, IsInt, IsOptional, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class SearchTripsDto {
@ApiProperty({ example: 'st_ADD' }) @IsString() originStationId: string;
@ApiProperty({ example: 'st_DJI' }) @IsString() destinationStationId: string;
@ApiProperty({ example: '2026-05-11' }) @IsDateString() date: string;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengers?: number;
}
export class FareQuoteDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty({ example: 'ECONOMY' }) @IsString() serviceClass: string;
@ApiPropertyOptional({ example: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) passengerCount?: number;
@ApiPropertyOptional({ example: 'WEEKEND15' }) @IsOptional() @IsString() promoCode?: string;
@ApiPropertyOptional({ example: 450 }) @IsOptional() @Type(() => Number) @IsInt() loyaltyRedemptionPoints?: number;
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
@Module({ controllers: [SearchController], providers: [SearchService], exports: [SearchService] })
export class SearchModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { SearchTripsDto, FareQuoteDto } from './search.dto';
const POINTS_TO_MINOR = 10;
@Injectable()
export class SearchService {
constructor(private prisma: PrismaService) {}
async searchTrips(dto: SearchTripsDto) {
const date = new Date(dto.date), nextDay = new Date(date.getTime() + 86400000);
const trips = await this.prisma.trip.findMany({
where: { originStationId: dto.originStationId, destinationStationId: dto.destinationStationId, departureAt: { gte: date, lt: nextDay }, status: { in: ['SCHEDULED', 'BOARDING'] } },
include: { service: true, originStation: true, destinationStation: true, coaches: { include: { seats: true } } },
});
return trips.map((trip) => {
const seatsByClass = (cls: string) => trip.coaches.filter((c) => c.serviceClass === cls).flatMap((c) => c.seats);
const avail = (cls: string) => seatsByClass(cls).filter((s) => s.status === 'AVAILABLE').length;
return {
id: trip.id,
number: trip.service.number,
origin: { id: trip.originStation.id, code: trip.originStation.code, name: trip.originStation.name, city: trip.originStation.city },
destination: { id: trip.destinationStation.id, code: trip.destinationStation.code, name: trip.destinationStation.name, city: trip.destinationStation.city },
departureAt: trip.departureAt, arrivalAt: trip.arrivalAt, status: trip.status,
availability: { ECONOMY: avail('ECONOMY'), BUSINESS: avail('BUSINESS'), FIRST: avail('FIRST') },
fares: { ECONOMY: this.defaultFare('ECONOMY') / 100, BUSINESS: this.defaultFare('BUSINESS') / 100, FIRST: this.defaultFare('FIRST') / 100 },
};
});
}
async getFareQuote(dto: FareQuoteDto) {
const trip = await this.prisma.trip.findUnique({ where: { id: dto.tripId } });
if (!trip) throw new NotFoundException('Trip not found');
const count = dto.passengerCount ?? 1;
const baseFareMinor = this.defaultFare(dto.serviceClass) * count;
let discountMinor = 0;
if (dto.promoCode) {
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
if (promo?.active && promo.validUntil > new Date()) discountMinor = promo.percentOff ? Math.round(baseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
}
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
const taxesMinor = Math.round(baseFareMinor * 0.05);
return { tripId: dto.tripId, serviceClass: dto.serviceClass, passengerCount: count, baseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor: Math.max(0, baseFareMinor - discountMinor - loyaltyMinor + taxesMinor), currency: 'ETB' };
}
private defaultFare(serviceClass: string): number {
return ({ ECONOMY: 45000, BUSINESS: 90000, FIRST: 135000 } as any)[serviceClass] ?? 45000;
}
}

View File

@@ -1,26 +0,0 @@
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

@@ -1,17 +1,17 @@
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SeatsService } from './seats.service';
import { HoldSeatsDto } from './seats.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { SeatsService } from "./seats.service";
@ApiTags("seats")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("seats")
@ApiTags('Seats')
@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);
}
constructor(private service: SeatsService) {}
@Get('seatmap/:tripId') @ApiOperation({ summary: 'Get seat map for a trip' })
getSeatMap(@Param('tripId') tripId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(tripId, coachId); }
@Post('hold') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Hold seats for 15 minutes' })
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
@Delete('hold/:holdId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Release a seat hold' })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
}

View File

@@ -0,0 +1,9 @@
import { IsString, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class HoldSeatsDto {
@ApiProperty() @IsString() tripId: string;
@ApiProperty() @IsString() passengerId: string;
@ApiProperty({ type: [String] }) @IsArray() seatIds: string[];
@ApiProperty({ required: false }) fareQuoteId?: string;
}

View File

@@ -1,14 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service';
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],
})
@Module({ controllers: [SeatsController], providers: [SeatsService], exports: [SeatsService] })
export class SeatsModule {}

View File

@@ -1,21 +1,50 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Seat } from "./entities/seat.entity";
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable()
export class SeatsService {
constructor(
@InjectRepository(Seat)
private readonly seatsRepository: Repository<Seat>,
) {}
constructor(private prisma: PrismaService) {}
/** 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" },
async getSeatMap(tripId: string, coachId?: string) {
const coaches = await this.prisma.coach.findMany({ where: { tripId, ...(coachId ? { id: coachId } : {}) }, include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } });
return {
coaches: coaches.map((coach) => ({
id: coach.id,
name: `Coach ${coach.label}`,
type: coach.serviceClass,
seats: coach.seats.map((s) => ({ id: s.id, number: s.label, status: s.status, kind: s.kind })),
})),
};
}
async holdSeats(dto: HoldSeatsDto) {
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
const hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({ where: { id: { in: dto.seatIds } }, select: { id: true, status: true, heldUntil: true } });
const unavailable = seats.filter((s) => s.status === 'BOOKED' || s.status === 'BLOCKED' || (s.status === 'HELD' && s.heldUntil && s.heldUntil > new Date()));
if (unavailable.length > 0) throw new ConflictException('One or more seats unavailable');
await tx.seat.updateMany({ where: { id: { in: dto.seatIds } }, data: { status: 'HELD', heldUntil: expiresAt } });
return tx.seatHold.create({ data: { tripId: dto.tripId, passengerId: dto.passengerId, seatIds: dto.seatIds, fareQuoteId: dto.fareQuoteId, expiresAt } });
});
return { id: hold.id, tripId: dto.tripId, seatIds: dto.seatIds, expiresAt };
}
async releaseHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found');
await this.prisma.seat.updateMany({ where: { id: { in: hold.seatIds }, status: 'HELD' }, data: { status: 'AVAILABLE', heldUntil: null } });
await this.prisma.seatHold.delete({ where: { id: holdId } });
return { released: true };
}
async confirmSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'BOOKED', heldUntil: null } }); }
async releaseSeats(seatIds: string[]) { await this.prisma.seat.updateMany({ where: { id: { in: seatIds } }, data: { status: 'AVAILABLE', heldUntil: null } }); }
@Cron(CronExpression.EVERY_MINUTE)
async expireHolds() {
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
for (const hold of expired) { await this.releaseSeats(hold.seatIds); await this.prisma.seatHold.delete({ where: { id: hold.id } }); }
}
}

View File

@@ -1,23 +0,0 @@
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

@@ -1,35 +0,0 @@
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

@@ -1,37 +1,15 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { CreateStationDto } from "./dto/create-station.dto";
import { StationsService } from "./stations.service";
@ApiTags("stations")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("stations")
@ApiTags('Stations')
@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);
}
constructor(private service: StationsService) {}
@Get() @ApiOperation({ summary: 'List all stations' }) findAll() { return this.service.findAll(); }
@Get(':id') @ApiOperation({ summary: 'Get station by ID' }) findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post() @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Create station' })
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsNumber, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateStationDto {
@ApiProperty({ example: 'ADD' }) @IsString() code: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
@ApiProperty({ example: 9.0054 }) @IsNumber() lat: number;
@ApiProperty({ example: 38.7636 }) @IsNumber() lng: number;
}

View File

@@ -1,14 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { StationsController } from './stations.controller';
import { StationsService } from './stations.service';
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],
})
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
export class StationsModule {}

View File

@@ -1,34 +1,15 @@
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";
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { CreateStationDto } from './stations.dto';
@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;
constructor(private prisma: PrismaService) {}
findAll() { return this.prisma.station.findMany({ orderBy: { name: 'asc' } }); }
async findOne(id: string) {
const s = await this.prisma.station.findUnique({ where: { id } });
if (!s) throw new NotFoundException('Station not found');
return s;
}
create(dto: CreateStationDto) { return this.prisma.station.create({ data: dto }); }
}

View File

@@ -0,0 +1,15 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SupportService } from './support.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Support')
@Controller('support')
export class SupportController {
constructor(private service: SupportService) {}
@Get('faq') @ApiOperation({ summary: 'Get FAQ categories' }) getFaqCategories() { return this.service.getFaqCategories(); }
@Get('faq/:categoryId/articles') @ApiOperation({ summary: 'Get FAQ articles for a category' }) getFaqArticles(@Param('categoryId') id: string) { return this.service.getFaqArticles(id); }
@Post('conversations') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Start a support conversation' }) startConversation(@Body('userId') userId: string) { return this.service.startConversation(userId); }
@Post('conversations/:id/messages') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Send a message in a conversation' }) sendMessage(@Param('id') id: string, @Body() body: { sender: 'USER' | 'BOT' | 'AGENT'; text: string }) { return this.service.sendMessage(id, body.sender, body.text); }
@Get('conversations/:id') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get conversation with messages' }) getConversation(@Param('id') id: string) { return this.service.getConversation(id); }
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { SupportController } from './support.controller';
import { SupportService } from './support.service';
@Module({ controllers: [SupportController], providers: [SupportService] })
export class SupportModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class SupportService {
constructor(private prisma: PrismaService) {}
getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); }
getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); }
startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); }
async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) {
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } });
if (!conv) throw new NotFoundException('Conversation not found');
const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } });
if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } });
return message;
}
async getConversation(conversationId: string) {
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } });
if (!conv) throw new NotFoundException('Conversation not found');
return conv;
}
private getBotReply(text: string): string {
const lower = text.toLowerCase();
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to My Bookings and select the booking. Refunds are processed within 3-5 business days.';
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
return 'Thank you for contacting EDR support. An agent will assist you shortly.';
}
}

View File

@@ -1,35 +0,0 @@
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

@@ -1,29 +0,0 @@
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

@@ -1,32 +0,0 @@
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

@@ -1,48 +1,14 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
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")
@ApiTags('Tickets')
@Controller('tickets')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
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);
}
constructor(private service: TicketsService) {}
@Get(':bookingRef') @ApiOperation({ summary: 'Get ticket by booking reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
@Post(':bookingRef/validate') @ApiOperation({ summary: 'Validate ticket at gate (staff)' }) validate(@Param('bookingRef') ref: string, @Body('validatorId') validatorId: string) { return this.service.validate(ref, validatorId); }
}

View File

@@ -1,15 +1,6 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Module } from '@nestjs/common';
import { TicketsController } from './tickets.controller';
import { TicketsService } from './tickets.service';
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],
})
@Module({ controllers: [TicketsController], providers: [TicketsService], exports: [TicketsService] })
export class TicketsModule {}

View File

@@ -1,21 +0,0 @@
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

@@ -1,53 +1,43 @@
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";
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import * as QRCode from 'qrcode';
@Injectable()
export class TicketsService {
constructor(private readonly ticketsRepository: TicketsRepository) {}
constructor(private prisma: PrismaService) {}
/** Issue a new passenger ticket. */
create(dto: CreateTicketDto): Promise<Ticket> {
return this.ticketsRepository.create({
...dto,
issuedAt: new Date(dto.issuedAt),
async generate(bookingId: string) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } },
});
if (!booking) throw new NotFoundException('Booking not found');
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
return this.prisma.ticket.upsert({ where: { bookingId }, update: { qrPayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload } });
}
/** 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" },
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
});
return { items, total };
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
return {
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
fromStationName: booking.trip.originStation.name, toStationName: booking.trip.destinationStation.name,
departureAt: booking.trip.departureAt, trainName: booking.trip.service.name,
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
};
}
/** 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);
async validate(bookingRef: string, validatorId: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
if (ticket.validatedAt) throw new BadRequestException('Ticket already validated');
return this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
}
}

View File

@@ -0,0 +1,14 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { WalletService } from './wallet.service';
import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Wallet')
@Controller('wallet')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class WalletController {
constructor(private service: WalletService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); }
@Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); }
}

View File

@@ -0,0 +1,6 @@
import { Module } from '@nestjs/common';
import { WalletController } from './wallet.controller';
import { WalletService } from './wallet.service';
@Module({ controllers: [WalletController], providers: [WalletService] })
export class WalletModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
@Injectable()
export class WalletService {
constructor(private prisma: PrismaService) {}
async getWallet(passengerId: string) {
const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } });
if (!wallet) throw new NotFoundException('Wallet not found');
return wallet;
}
async topUp(passengerId: string, amountMinor: number, description = 'Top-up') {
const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId } });
if (!wallet) throw new NotFoundException('Wallet not found');
const newBalance = wallet.balanceMinor + amountMinor;
await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } });
return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } });
}
}