mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
Initial commit of edr-passenger-api alpha version
This commit is contained in:
18
apps/edr-passenger-api/src/modules/auth/auth.controller.ts
Normal file
18
apps/edr-passenger-api/src/modules/auth/auth.controller.ts
Normal 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); }
|
||||
}
|
||||
14
apps/edr-passenger-api/src/modules/auth/auth.dto.ts
Normal file
14
apps/edr-passenger-api/src/modules/auth/auth.dto.ts
Normal 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;
|
||||
}
|
||||
24
apps/edr-passenger-api/src/modules/auth/auth.module.ts
Normal file
24
apps/edr-passenger-api/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
42
apps/edr-passenger-api/src/modules/auth/auth.service.ts
Normal file
42
apps/edr-passenger-api/src/modules/auth/auth.service.ts
Normal 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 } };
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
22
apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
Normal file
22
apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' } }); }
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
Normal file
18
apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
Normal 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(); }
|
||||
}
|
||||
20
apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts
Normal file
20
apps/edr-passenger-api/src/modules/fleet/fleet.dto.ts
Normal 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[];
|
||||
}
|
||||
6
apps/edr-passenger-api/src/modules/fleet/fleet.module.ts
Normal file
6
apps/edr-passenger-api/src/modules/fleet/fleet.module.ts
Normal 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 {}
|
||||
26
apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
Normal file
26
apps/edr-passenger-api/src/modules/fleet/fleet.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
16
apps/edr-passenger-api/src/modules/live/live.controller.ts
Normal file
16
apps/edr-passenger-api/src/modules/live/live.controller.ts
Normal 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(); }
|
||||
}
|
||||
11
apps/edr-passenger-api/src/modules/live/live.dto.ts
Normal file
11
apps/edr-passenger-api/src/modules/live/live.dto.ts
Normal 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;
|
||||
}
|
||||
6
apps/edr-passenger-api/src/modules/live/live.module.ts
Normal file
6
apps/edr-passenger-api/src/modules/live/live.module.ts
Normal 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 {}
|
||||
35
apps/edr-passenger-api/src/modules/live/live.service.ts
Normal file
35
apps/edr-passenger-api/src/modules/live/live.service.ts
Normal 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' } }); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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, ' ')); }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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' } }); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}` }; }
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
22
apps/edr-passenger-api/src/modules/payments/payments.dto.ts
Normal file
22
apps/edr-passenger-api/src/modules/payments/payments.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
13
apps/edr-passenger-api/src/modules/promos/promos.dto.ts
Normal file
13
apps/edr-passenger-api/src/modules/promos/promos.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
20
apps/edr-passenger-api/src/modules/promos/promos.service.ts
Normal file
20
apps/edr-passenger-api/src/modules/promos/promos.service.ts
Normal 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) } });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
18
apps/edr-passenger-api/src/modules/search/search.dto.ts
Normal file
18
apps/edr-passenger-api/src/modules/search/search.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
50
apps/edr-passenger-api/src/modules/search/search.service.ts
Normal file
50
apps/edr-passenger-api/src/modules/search/search.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
9
apps/edr-passenger-api/src/modules/seats/seats.dto.ts
Normal file
9
apps/edr-passenger-api/src/modules/seats/seats.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
11
apps/edr-passenger-api/src/modules/stations/stations.dto.ts
Normal file
11
apps/edr-passenger-api/src/modules/stations/stations.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 }); }
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 {}
|
||||
21
apps/edr-passenger-api/src/modules/wallet/wallet.service.ts
Normal file
21
apps/edr-passenger-api/src/modules/wallet/wallet.service.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user