Initial commit of edr-passenger-api alpha version

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

View File

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

View File

@@ -1,29 +0,0 @@
import { Passenger } from "@edr/types";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
export class FilterTicketDto {
@IsOptional()
@IsEnum(Passenger.TicketStatus)
status?: Passenger.TicketStatus;
@IsOptional()
@IsUUID()
passengerId?: string;
@IsOptional()
@IsUUID()
scheduleId?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number = 20;
}

View File

@@ -1,32 +0,0 @@
import { BaseEntity } from "@edr/api-common";
import { Passenger } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "tickets" })
export class Ticket extends BaseEntity {
@Column({ name: "reference", type: "varchar", length: 64, unique: true })
reference!: string;
@Column({ name: "passenger_id", type: "uuid" })
passengerId!: string;
@Column({ name: "schedule_id", type: "uuid" })
scheduleId!: string;
@Column({ name: "seat_id", type: "uuid" })
seatId!: string;
@Column({
name: "status",
type: "enum",
enum: Passenger.TicketStatus,
default: Passenger.TicketStatus.Reserved,
})
status!: Passenger.TicketStatus;
@Column({ name: "price_paid", type: "numeric", precision: 10, scale: 2 })
pricePaid!: number;
@Column({ name: "issued_at", type: "timestamptz" })
issuedAt!: Date;
}

View File

@@ -1,48 +1,14 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
import { CreateTicketDto } from "./dto/create-ticket.dto";
import { FilterTicketDto } from "./dto/filter-ticket.dto";
import { TicketsService } from "./tickets.service";
@ApiTags("tickets")
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
@Controller("tickets")
@ApiTags('Tickets')
@Controller('tickets')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class TicketsController {
constructor(private readonly ticketsService: TicketsService) {}
@Post()
@ApiOperation({ summary: "Issue a new passenger ticket" })
create(@Body() dto: CreateTicketDto) {
return this.ticketsService.create(dto);
}
@Get()
@ApiOperation({ summary: "List passenger tickets (paginated)" })
findAll(@Query() filter: FilterTicketDto) {
return this.ticketsService.findAll(filter);
}
@Get(":id")
@ApiOperation({ summary: "Get a ticket by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.ticketsService.findById(id);
}
@Delete(":id")
@HttpCode(204)
@ApiOperation({ summary: "Cancel a ticket" })
cancel(@Param("id", ParseUUIDPipe) id: string) {
return this.ticketsService.cancel(id);
}
constructor(private service: TicketsService) {}
@Get(':bookingRef') @ApiOperation({ summary: 'Get ticket by booking reference' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); }
@Post(':bookingRef/validate') @ApiOperation({ summary: 'Validate ticket at gate (staff)' }) validate(@Param('bookingRef') ref: string, @Body('validatorId') validatorId: string) { return this.service.validate(ref, validatorId); }
}

View File

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

View File

@@ -1,21 +0,0 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Ticket } from "./entities/ticket.entity";
@Injectable()
export class TicketsRepository extends BaseRepository<Ticket> {
constructor(
@InjectRepository(Ticket)
repository: Repository<Ticket>,
) {
super(repository);
}
/** Find a ticket by its passenger-facing reference. */
findByReference(reference: string): Promise<Ticket | null> {
return this.repository.findOne({ where: { reference } });
}
}

View File

@@ -1,53 +1,43 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreateTicketDto } from "./dto/create-ticket.dto";
import { FilterTicketDto } from "./dto/filter-ticket.dto";
import { Ticket } from "./entities/ticket.entity";
import { TicketsRepository } from "./tickets.repository";
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import * as QRCode from 'qrcode';
@Injectable()
export class TicketsService {
constructor(private readonly ticketsRepository: TicketsRepository) {}
constructor(private prisma: PrismaService) {}
/** Issue a new passenger ticket. */
create(dto: CreateTicketDto): Promise<Ticket> {
return this.ticketsRepository.create({
...dto,
issuedAt: new Date(dto.issuedAt),
async generate(bookingId: string) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } } },
});
if (!booking) throw new NotFoundException('Booking not found');
const qrPayload = await QRCode.toDataURL(`edr:tkt:${booking.id}:${booking.bookingRef}`);
return this.prisma.ticket.upsert({ where: { bookingId }, update: { qrPayload }, create: { bookingId, bookingRef: booking.bookingRef, qrPayload } });
}
/** Paginated list of tickets matching the filter. */
async findAll(
filter: FilterTicketDto,
): Promise<{ items: Ticket[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const [items, total] = await this.ticketsRepository.findAndCount({
where: {
...(filter.status ? { status: filter.status } : {}),
...(filter.passengerId ? { passengerId: filter.passengerId } : {}),
...(filter.scheduleId ? { scheduleId: filter.scheduleId } : {}),
},
skip: (page - 1) * pageSize,
take: pageSize,
order: { createdAt: "DESC" },
async getByRef(bookingRef: string) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef },
include: { trip: { include: { originStation: true, destinationStation: true, service: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
});
return { items, total };
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
const seat = booking.seats[0];
return {
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
fromStationName: booking.trip.originStation.name, toStationName: booking.trip.destinationStation.name,
departureAt: booking.trip.departureAt, trainName: booking.trip.service.name,
coachLabel: seat?.seat.coach.label, seatLabel: seat?.seat.label, passengerName: seat?.passengerName,
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
};
}
/** Get a single ticket by ID. */
async findById(id: string): Promise<Ticket> {
const ticket = await this.ticketsRepository.findById(id);
if (!ticket) {
throw new NotFoundException(`Ticket ${id} not found`);
}
return ticket;
}
/** Cancel and soft-delete a ticket. */
async cancel(id: string): Promise<void> {
await this.findById(id);
await this.ticketsRepository.softDelete(id);
async validate(bookingRef: string, validatorId: string) {
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
if (!booking) throw new NotFoundException('Booking not found');
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
if (!ticket) throw new NotFoundException('Ticket not found');
if (ticket.validatedAt) throw new BadRequestException('Ticket already validated');
return this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: new Date(), validatorId } });
}
}