feat: ( reschedule ) manage reschedule policies from a Master Data page with full CRUD

This commit is contained in:
Abubeker Yasin
2026-08-24 10:44:28 +03:00
parent 0fd6a1d7d7
commit 2c9453f87d
9 changed files with 554 additions and 154 deletions

View File

@@ -1,10 +1,15 @@
import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { RescheduleService } from './reschedule.service';
import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto';
import {
CreateReschedulePolicyDto,
CreateRescheduleDto,
RescheduleQuoteDto,
UpdateReschedulePolicyDto,
} from './reschedule.dto';
@ApiTags('Reschedule')
@Controller()
@@ -14,11 +19,27 @@ export class RescheduleController {
@Get('reschedule/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reschedule policy per coach type (fare class)' })
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
listPolicies() {
return this.service.listPolicies();
}
@Get('reschedule/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() {
return this.service.listUnconfiguredCoachTypes();
}
@Post('reschedule/policies')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
return this.service.createPolicy(dto, req.user?.id);
}
@Patch('reschedule/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@@ -27,6 +48,14 @@ export class RescheduleController {
return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
}
@Delete('reschedule/policies/:coachTypeId')
@PassengerAdmin()
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
return this.service.deletePolicy(coachTypeId, req.user?.id);
}
@Get('bookings/:bookingRef/reschedule')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')

View File

@@ -45,6 +45,13 @@ export class UpdateReschedulePolicyDto {
isActive?: boolean;
}
/** Same fields as the update DTO, plus the fare class the new policy attaches to. */
export class CreateReschedulePolicyDto extends UpdateReschedulePolicyDto {
@ApiProperty({ example: 'coach-type-uuid', description: 'CoachType the policy applies to (one policy per fare class)' })
@IsString()
coachTypeId: string;
}
export class RescheduleQuoteDto {
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
@@ -19,11 +20,33 @@ import { TicketsService } from '../tickets/tickets.service';
import { PaymentsService } from '../payments/payments.service';
import { SupplementaryChargesService } from '../payments/supplementary-charges.service';
import { CurrencyService } from '../currency/currency.service';
import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto';
import {
CreateReschedulePolicyDto,
CreateRescheduleDto,
RescheduleQuoteDto,
UpdateReschedulePolicyDto,
} from './reschedule.dto';
export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE';
export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid';
/**
* Coaches nobody buys a seat in, so they can never carry a reschedule policy.
*
* Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' |
* 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space
* included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the
* dining coach as a fare class. This mirrors the portal's own test (`/dining|dpc/i`,
* booking/seats/page.tsx) and checks `code` as well as `type`.
*/
const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage'];
const NOT_A_FARE_CLASS = {
NOT: NON_FARE_COACH_TERMS.flatMap((term) => [
{ type: { contains: term, mode: 'insensitive' as const } },
{ code: { contains: term, mode: 'insensitive' as const } },
]),
};
type PolicyNumbers = {
feePercent: number;
feeMinMinor: number;
@@ -90,18 +113,62 @@ export class RescheduleService {
// ── Policy admin ─────────────────────────────────────────────────────────
/** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */
async listPolicies() {
const coachTypes = await this.prisma.coachType.findMany({
where: { type: { notIn: ['dining', 'baggage'] } },
include: { reschedulePolicy: true },
return this.prisma.reschedulePolicy.findMany({
include: { coachType: { select: { id: true, code: true, name: true, type: true } } },
orderBy: { coachType: { code: 'asc' } },
});
}
/** Fare classes still available to attach a policy to — the "add" dialog's dropdown. */
async listUnconfiguredCoachTypes() {
return this.prisma.coachType.findMany({
where: { ...NOT_A_FARE_CLASS, reschedulePolicy: { is: null } },
select: { id: true, code: true, name: true, type: true },
orderBy: { code: 'asc' },
});
return coachTypes.map((ct) => ({
coachTypeId: ct.id,
code: ct.code,
name: ct.name,
policy: ct.reschedulePolicy,
}));
}
async createPolicy(dto: CreateReschedulePolicyDto, actorId?: string) {
const { coachTypeId, ...values } = dto;
const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } });
if (!coachType) throw new NotFoundException('Coach type not found');
if (NON_FARE_COACH_TERMS.some((t) => `${coachType.type} ${coachType.code}`.toLowerCase().includes(t))) {
throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`);
}
const existing = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } });
if (existing) throw new ConflictException(`${coachType.code} already has a reschedule policy — edit it instead.`);
const policy = await this.prisma.reschedulePolicy.create({ data: { coachTypeId, ...values } });
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.CREATE,
entityType: AUDIT_ENTITIES.ReschedulePolicy,
entityId: policy.id,
newData: { coachTypeCode: coachType.code, ...values },
});
return policy;
}
async deletePolicy(coachTypeId: string, actorId?: string) {
const policy = await this.prisma.reschedulePolicy.findUnique({
where: { coachTypeId },
include: { coachType: { select: { code: true } } },
});
if (!policy) throw new NotFoundException('Reschedule policy not found');
await this.prisma.reschedulePolicy.delete({ where: { coachTypeId } });
await this.auditService.log({
userId: actorId,
action: AUDIT_ACTIONS.DELETE,
entityType: AUDIT_ENTITIES.ReschedulePolicy,
entityId: policy.id,
oldData: policy,
});
// Rescheduling for this fare class is now refused outright (legBlockers treats a missing
// policy the same as an inactive one), which is the intended effect of deleting it.
return { deleted: true, coachTypeId };
}
async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, actorId?: string) {