Backoffice UAT results addressed, packages and other updates

This commit is contained in:
Stephanos A
2026-06-25 20:10:36 +03:00
parent 9e84a022df
commit cfbbd437e9
28 changed files with 1042 additions and 211 deletions

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
@ApiTags('Agents')
@@ -16,6 +16,24 @@ export class AgentsController {
getMe(@Request() req: any) {
return this.service.getMe(req.user?.id ?? req.user?.sub);
}
@Get()
@ApiOperation({ summary: 'List all agents' })
findAll(@Query('search') search?: string, @Query('active') active?: string) {
return this.service.findAll({ search, active });
}
@Post()
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
createAgent(@Body() dto: CreateAgentDto) {
return this.service.createAgent(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update agent profile' })
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
return this.service.updateAgent(id, dto);
}
@Post('bookings')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {

View File

@@ -2,6 +2,12 @@ import { IsString, IsInt, IsBoolean, IsOptional, IsArray, ValidateNested } from
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateAgentDto {
@ApiProperty() @IsString() iamUserId: string;
@ApiPropertyOptional() @IsOptional() @IsString() agentCode?: string;
@ApiPropertyOptional() @IsOptional() @IsInt() commissionRate?: number;
}
export class AgentPassengerDto {
@ApiProperty() @IsString() fullName: string;
@ApiProperty() @IsString() phone: string;

View File

@@ -1,4 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { IdDocumentType } from '@prisma/client';
@@ -10,7 +12,49 @@ function generateRef(): string {
@Injectable()
export class AgentsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
@InjectDataSource() private readonly dataSource: DataSource,
) {}
async findAll(filters: { search?: string; active?: string }) {
const where: any = {};
if (filters.active !== undefined && filters.active !== '') {
where.active = filters.active === 'true';
}
const agents = await this.prisma.agent.findMany({
where,
orderBy: { createdAt: 'desc' },
});
// Enrich with IAM user data
const iamUserIds = agents.map(a => a.iamUserId).filter(Boolean) as string[];
type IamRow = { id: string; email: string; name: any; phone_number: string | null };
const iamRows: IamRow[] = iamUserIds.length > 0
? await this.dataSource.query<IamRow[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
).catch(() => [])
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
const items = agents
.map(a => {
const iam = a.iamUserId ? iamMap.get(a.iamUserId) ?? null : null;
const fullName = iam?.name?.en ?? iam?.name?.am ?? null;
if (filters.search) {
const q = filters.search.toLowerCase();
const matches = a.agentCode.toLowerCase().includes(q)
|| (iam?.email ?? '').toLowerCase().includes(q)
|| (fullName ?? '').toLowerCase().includes(q);
if (!matches) return null;
}
return {
...a,
user: iam ? { fullName, email: iam.email, phone: iam.phone_number } : null,
};
})
.filter(Boolean);
return { items, total: items.length };
}
async createAgentBooking(dto: CreateAgentBookingDto) {
const agent = await this.prisma.agent.findUnique({ where: { id: dto.agentId } });
@@ -139,4 +183,31 @@ export class AgentsService {
if (!agent) throw new NotFoundException('No agent profile found for this user');
return agent;
}
async createAgent(dto: { iamUserId: string; agentCode?: string; commissionRate?: number }) {
const existing = await this.prisma.agent.findUnique({ where: { iamUserId: dto.iamUserId } });
if (existing) throw new BadRequestException('An agent profile already exists for this user');
const agentCode = dto.agentCode || `AG${String(Date.now()).slice(-4)}`;
return this.prisma.agent.create({
data: {
iamUserId: dto.iamUserId,
agentCode,
commissionRate: dto.commissionRate ?? 5,
active: true,
},
});
}
async updateAgent(id: string, dto: { agentCode?: string; commissionRate?: number; active?: boolean }) {
const agent = await this.prisma.agent.findUnique({ where: { id } });
if (!agent) throw new NotFoundException('Agent not found');
return this.prisma.agent.update({
where: { id },
data: {
...(dto.agentCode !== undefined && { agentCode: dto.agentCode }),
...(dto.commissionRate !== undefined && { commissionRate: dto.commissionRate }),
...(dto.active !== undefined && { active: dto.active }),
},
});
}
}

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';
import { ExcessBaggageService } from './excess-baggage.service';
import {
LogExcessBaggageDto,
@@ -8,6 +9,13 @@ import {
} from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
class UpsertBaggageAllowanceDto {
@IsString() seatClassId: string;
@IsInt() @IsPositive() maxWeightKg: number;
@IsInt() @IsPositive() maxPiecesCount: number;
@IsInt() @IsPositive() excessFeePerKg: number;
}
// ── IAM-protected agent/supervisor routes ────────────────────────────────────
@ApiTags('Excess Baggage')
@Controller('agents/excess-baggage')
@@ -55,6 +63,30 @@ export class ExcessBaggageAgentController {
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
@Get('allowances')
@ApiOperation({ summary: 'List all baggage allowance rules' })
getAllowances() {
return this.service.getAllowances();
}
@Post('allowances')
@ApiOperation({ summary: 'Create baggage allowance rule for a seat class' })
createAllowance(@Body() dto: UpsertBaggageAllowanceDto) {
return this.service.upsertAllowance(dto);
}
@Patch('allowances/:id')
@ApiOperation({ summary: 'Update baggage allowance rule' })
updateAllowance(@Param('id') id: string, @Body() dto: Partial<UpsertBaggageAllowanceDto>) {
return this.service.updateAllowance(id, dto);
}
@Delete('allowances/:id')
@ApiOperation({ summary: 'Delete baggage allowance rule' })
deleteAllowance(@Param('id') id: string) {
return this.service.deleteAllowance(id);
}
}
// ── Public pay-by-token routes (passenger self-service) ──────────────────────

View File

@@ -249,4 +249,30 @@ export class ExcessBaggageService {
return { items, total, page, pageSize };
}
async getAllowances() {
const [allowances, seatClasses] = await Promise.all([
this.prisma.baggageAllowance.findMany({ orderBy: { createdAt: 'asc' } }),
this.prisma.seatClass.findMany({ select: { id: true, name: true } }),
]);
const scMap = new Map(seatClasses.map(s => [s.id, s]));
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
}
async upsertAllowance(dto: { seatClassId: string; maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }) {
return this.prisma.baggageAllowance.upsert({
where: { seatClassId: dto.seatClassId } as any,
update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
});
}
async updateAllowance(id: string, dto: Partial<{ maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }>) {
return this.prisma.baggageAllowance.update({ where: { id }, data: dto });
}
async deleteAllowance(id: string) {
await this.prisma.baggageAllowance.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -153,6 +153,15 @@ export class FleetController {
return this.service.deleteTrain(id);
}
@Patch('trains/:id/restore')
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train restored' })
@ApiResponse({ status: 404, description: 'Train not found' })
restoreTrain(@Param('id') id: string) {
return this.service.restoreTrain(id);
}
// Coach Endpoints
@Get('coaches')
@ApiOperation({ summary: 'List coaches with seat status summary' })

View File

@@ -7,6 +7,7 @@ export class CreateTrainDto {
@ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @IsBoolean() isActive?: boolean;
}
export class CreateCoachDto {
@@ -26,6 +27,8 @@ export class CreateCoachDto {
description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.',
})
@IsOptional() @IsInt() bedsPerRoom?: number;
@ApiPropertyOptional({ example: 1, description: 'Sequence number for ordering coaches in the train' })
@IsOptional() @IsInt() sequence?: number;
}
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
@@ -66,6 +69,9 @@ export class CreateClassDto {
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
@IsOptional() @IsString() description?: string;
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
}
export class UpdateClassDto {

View File

@@ -224,6 +224,9 @@ export class FleetService {
name: dto.name,
description: dto.description,
baseFareMinor: dto.baseFareMinor,
...(dto.premiumMinor !== undefined && { premiumMinor: dto.premiumMinor }),
...(dto.insuranceFeeMinor !== undefined && { insuranceFeeMinor: dto.insuranceFeeMinor }),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
}
@@ -307,34 +310,54 @@ export class FleetService {
}
createTrain(dto: CreateTrainDto) {
return this.prisma.train.create({ data: dto });
return this.prisma.train.create({
data: {
number: dto.number,
name: dto.name,
operatorId: dto.operatorId,
operatorName: dto.operatorName,
description: dto.description,
isActive: dto.isActive ?? true,
},
});
}
async updateTrain(id: string, dto: CreateTrainDto) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.update({ where: { id }, data: dto });
return this.prisma.train.update({
where: { id },
data: {
number: dto.number,
name: dto.name,
operatorId: dto.operatorId,
operatorName: dto.operatorName,
description: dto.description,
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
}
async deleteTrain(id: string) {
const train = await this.prisma.train.findUnique({
where: { id },
include: {
schedules: true,
},
include: { schedules: true },
});
if (!train) throw new NotFoundException('Train not found');
// Check for active schedules
if (train.schedules.length > 0) {
throw new BadRequestException(
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
);
}
return this.prisma.train.delete({ where: { id } });
}
async restoreTrain(id: string) {
const train = await this.prisma.train.findUnique({ where: { id } });
if (!train) throw new NotFoundException('Train not found');
return this.prisma.train.update({ where: { id }, data: { isActive: true } });
}
async getCoach(id: string) {
const coach = await this.prisma.coach.findUnique({
where: { id },
@@ -370,18 +393,20 @@ export class FleetService {
throw new BadRequestException(`Invalid arrangement format "${dto.arrangement}". Use e.g. "2+2"`);
}
// Get the next sequence number for this coach type
const lastCoach = await this.prisma.coach.findFirst({
where: { coachTypeId: dto.coachTypeId },
orderBy: { sequence: 'desc' },
});
const nextSequence = (lastCoach?.sequence ?? 0) + 1;
// Use user-provided sequence or auto-assign the next one
let resolvedSequence = dto.sequence;
if (resolvedSequence === undefined || resolvedSequence === null) {
const lastCoach = await this.prisma.coach.findFirst({
orderBy: { sequence: 'desc' },
});
resolvedSequence = (lastCoach?.sequence ?? 0) + 1;
}
const coach = await this.prisma.coach.create({
data: {
coachTypeId: dto.coachTypeId,
number: dto.number,
sequence: nextSequence,
sequence: resolvedSequence,
arrangement: dto.arrangement,
capacity: dto.capacity,
status: dto.status || 'ACTIVE',

View File

@@ -19,9 +19,8 @@ export class PackagesController {
}
@Get('all')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List all packages (admin)' })
@IsPublic()
@ApiOperation({ summary: 'List all packages' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
}
@@ -64,6 +63,14 @@ export class PackagesController {
return this.service.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete package (admin)' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
@Patch(':id/activate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -117,6 +117,28 @@ export class PackagesService {
return this.prisma.packagePriceTier.delete({ where: { id: tierId } });
}
async remove(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id },
include: { bookings: { select: { id: true, status: true } } },
});
if (!pkg) throw new NotFoundException('Package not found');
const hasActive = pkg.bookings.some((b) => b.status === 'PENDING_PAYMENT' || b.status === 'CONFIRMED');
if (hasActive) throw new BadRequestException('Cannot delete a package with active bookings');
await this.prisma.$transaction(async (tx) => {
const bookingIds = pkg.bookings.map((b) => b.id);
if (bookingIds.length > 0) {
await tx.packageBookingPassenger.deleteMany({ where: { bookingId: { in: bookingIds } } });
await tx.packagePaymentIntent.deleteMany({ where: { packageBookingId: { in: bookingIds } } });
await tx.packageBooking.deleteMany({ where: { packageId: id } });
}
await tx.packagePriceTier.deleteMany({ where: { packageId: id } });
await tx.travelPackage.delete({ where: { id } });
});
return { deleted: true };
}
async activate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
@@ -228,7 +250,11 @@ export class PackagesService {
this.prisma.travelPackage.findMany({
skip,
take: pageSize,
include: { priceTiers: true },
include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true } },
returnSchedule: { include: { originStation: true, destinationStation: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.travelPackage.count(),

View File

@@ -432,10 +432,12 @@ export class PassengersService {
this.prisma.notification.deleteMany({ where: { passengerId: id } }),
this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }),
this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }),
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }),
this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }),
this.prisma.booking.deleteMany({ where: { passengerId: id } }),
this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }),
this.prisma.journey.deleteMany({ where: { passengerId: id } }),
this.prisma.passenger.delete({ where: { id } }),
]);

View File

@@ -14,6 +14,7 @@ export class CreateRouteDto {
@ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string;
@ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean;
@ApiProperty({
type: [RouteStopInputDto],
description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.',

View File

@@ -26,6 +26,7 @@ export class RoutesService {
code: dto.code,
name: dto.name,
description: dto.description,
active: dto.active ?? true,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
stops: {

View File

@@ -306,8 +306,18 @@ export class SchedulesService {
}
async deleteSchedule(id: string) {
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id },
include: { _count: { select: { bookings: true } } },
});
if (!schedule) throw new NotFoundException('Schedule not found');
if ((schedule as any)._count.bookings > 0) {
throw new BadRequestException(
`Cannot delete schedule. It has ${(schedule as any)._count.bookings} booking(s). Cancel all bookings before deleting.`,
);
}
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
return this.prisma.trainSchedule.delete({ where: { id } });
}

View File

@@ -166,4 +166,12 @@ export class TicketsController {
delete(@Param('id') id: string) {
return this.service.delete(id);
}
@Patch(':id/restore')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
restore(@Param('id') id: string) {
return this.service.restore(id);
}
}

View File

@@ -567,4 +567,10 @@ export class TicketsService {
return { deleted: true, ticketId: id };
}
async restore(id: string) {
const ticket = await this.prisma.ticket.findUnique({ where: { id } });
if (!ticket) throw new NotFoundException('Ticket not found');
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
}
}