Merge pull request #300 from Tria-plc/alpha

Throttling configuration from the passenger back office app
This commit is contained in:
Eyob T.
2026-06-26 12:23:46 +03:00
committed by GitHub
8 changed files with 152 additions and 21 deletions

View File

@@ -4,7 +4,8 @@ import {
NestModule,
OnApplicationBootstrap,
} from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerModule } from '@nestjs/throttler';
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@@ -135,7 +136,8 @@ import { TasksModule } from './modules/tasks/tasks.module';
TasksModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
DynamicThrottlerGuard,
EdrPassengerOrgSeeder,
PassengerStaffUsersSeeder,
],

View File

@@ -0,0 +1,34 @@
import { Injectable, ExecutionContext, Inject } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler';
import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service';
@Injectable()
export class DynamicThrottlerGuard extends ThrottlerGuard {
constructor(
@Inject(getOptionsToken()) options: any,
@Inject(getStorageToken()) storageService: ThrottlerStorage,
reflector: Reflector,
private readonly systemConfig: SystemConfigService,
) {
super(options, storageService, reflector);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_TTL_MS),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_STRICT_LIMIT),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_STRICT_TTL_MS),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_LIMIT),
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS),
]);
this.throttlers = [
{ name: 'default', ttl: defaultTtl, limit: defaultLimit },
];
return super.canActivate(context);
}
}

View File

@@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto';
import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
@@ -19,8 +20,9 @@ export class PackagesController {
}
@Get('all')
@IsPublic()
@ApiOperation({ summary: 'List all packages' })
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all packages (backoffice)' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
}
@@ -48,56 +50,64 @@ export class PackagesController {
}
@Post()
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) {
return this.service.create(dto);
}
@Patch(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
return this.service.update(id, dto);
}
@Delete(':id')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete package (admin)' })
remove(@Param('id') id: string) {
return this.service.remove(id);
}
@Patch(':id/activate')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) {
return this.service.activate(id);
}
@Patch(':id/deactivate')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Deactivate package (admin)' })
deactivate(@Param('id') id: string) {
return this.service.deactivate(id);
}
@Post(':id/tiers')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
return this.service.addTier(id, dto);
}
@Patch('tiers/:tierId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
return this.service.updateTier(tierId, dto);
}
@Delete('tiers/:tierId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@UseGuards(IamGuard)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete price tier (admin)' })
deleteTier(@Param('tierId') tierId: string) {
return this.service.deleteTier(tierId);

View File

@@ -20,7 +20,7 @@ export class PackagesService {
listActive() {
const now = new Date();
return this.prisma.travelPackage.findMany({
where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } },
where: { status: 'ACTIVE', validUntil: { gte: now } },
include: {
priceTiers: true,
outboundSchedule: { include: { originStation: true, destinationStation: true } },
@@ -145,6 +145,12 @@ export class PackagesService {
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
}
async deactivate(id: string) {
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
if (!pkg) throw new NotFoundException('Package not found');
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'DRAFT' } });
}
async book(dto: BookPackageDto, passengerId?: string) {
const pkg = await this.prisma.travelPackage.findUnique({
where: { id: dto.packageId },
@@ -152,7 +158,6 @@ export class PackagesService {
});
if (!pkg) throw new NotFoundException('Package not found');
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired');
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
if (!tier) throw new NotFoundException('Price tier not found');

View File

@@ -4,11 +4,23 @@ import { PrismaService } from '../../common/prisma.service';
export const CONFIG_KEYS = {
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE: 'hold_cutoff_hours_before_departure',
THROTTLE_AUTH_LIMIT: 'throttle_auth_limit',
THROTTLE_AUTH_TTL_MS: 'throttle_auth_ttl_ms',
THROTTLE_STRICT_LIMIT: 'throttle_strict_limit',
THROTTLE_STRICT_TTL_MS: 'throttle_strict_ttl_ms',
THROTTLE_DEFAULT_LIMIT: 'throttle_default_limit',
THROTTLE_DEFAULT_TTL_MS: 'throttle_default_ttl_ms',
} as const;
const DEFAULTS: Record<string, string> = {
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
[CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE]: '2',
[CONFIG_KEYS.THROTTLE_AUTH_LIMIT]: '5',
[CONFIG_KEYS.THROTTLE_AUTH_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_STRICT_LIMIT]: '20',
[CONFIG_KEYS.THROTTLE_STRICT_TTL_MS]: '60000',
[CONFIG_KEYS.THROTTLE_DEFAULT_LIMIT]: '100',
[CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS]: '60000',
};
@Injectable()

View File

@@ -34,6 +34,7 @@ export default function PackagesPage() {
const [editingId, setEditingId] = useState<string | null>(null);
const [viewPackage, setViewPackage] = useState<any>(null);
const [activateConfirm, setActivateConfirm] = useState<any>(null);
const [deactivateConfirm, setDeactivateConfirm] = useState<any>(null);
const [tiersPackage, setTiersPackage] = useState<any>(null);
const [editingTier, setEditingTier] = useState<any>(null);
const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' });
@@ -76,6 +77,11 @@ export default function PackagesPage() {
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setActivateConfirm(null); },
});
const deactivateMutation = useMutation({
mutationFn: packagesApi.deactivate,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); },
});
const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' };
const addTierMutation = useMutation({
@@ -261,7 +267,12 @@ export default function PackagesPage() {
{
label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
onClick: (p: any) => setActivateConfirm(p),
hidden: (p: any) => p.status === 'ACTIVE',
show: (p: any) => p.status !== 'ACTIVE',
},
{
label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const,
onClick: (p: any) => setDeactivateConfirm(p),
show: (p: any) => p.status === 'ACTIVE',
},
{
label: 'Delete', icon: Trash2, variant: 'danger' as const,
@@ -343,6 +354,18 @@ export default function PackagesPage() {
isDanger={false}
/>
{/* Deactivate Confirmation */}
<ConfirmDialog
isOpen={!!deactivateConfirm}
onClose={() => setDeactivateConfirm(null)}
onConfirm={() => deactivateMutation.mutate(deactivateConfirm.id)}
title="Deactivate Package"
message={`Deactivate "${deactivateConfirm?.name}"? It will no longer be available for booking.`}
confirmText="Deactivate"
isDanger={false}
isLoading={deactivateMutation.isPending}
/>
{/* Tiers Modal */}
<Modal isOpen={!!tiersPackage} onClose={() => { setTiersPackage(null); setEditingTier(null); setTierError(null); }} title={`Price Tiers — ${tiersPackage?.name ?? ''}`} size="lg">
{tiersPackage && (

View File

@@ -10,6 +10,9 @@ export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [holdCutoffHours, setHoldCutoffHours] = useState('2');
const [throttleAuthLimit, setThrottleAuthLimit] = useState('5');
const [throttleStrictLimit, setThrottleStrictLimit] = useState('20');
const [throttleDefaultLimit, setThrottleDefaultLimit] = useState('100');
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [configMessage, setConfigMessage] = useState('');
@@ -21,6 +24,9 @@ export default function SettingsPage() {
.then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
if (data?.hold_cutoff_hours_before_departure) setHoldCutoffHours(data.hold_cutoff_hours_before_departure);
if (data?.throttle_auth_limit) setThrottleAuthLimit(data.throttle_auth_limit);
if (data?.throttle_strict_limit) setThrottleStrictLimit(data.throttle_strict_limit);
if (data?.throttle_default_limit) setThrottleDefaultLimit(data.throttle_default_limit);
})
.catch(() => {})
.finally(() => setConfigLoading(false));
@@ -33,6 +39,9 @@ export default function SettingsPage() {
await systemConfigApi.update({
seat_hold_duration_minutes: seatHoldMinutes,
hold_cutoff_hours_before_departure: holdCutoffHours,
throttle_auth_limit: throttleAuthLimit,
throttle_strict_limit: throttleStrictLimit,
throttle_default_limit: throttleDefaultLimit,
});
setConfigMessage('Saved successfully.');
} catch {
@@ -177,6 +186,41 @@ export default function SettingsPage() {
{activeTab === 'configurations' && (
<div className="card space-y-6">
<h3 className="text-lg font-semibold text-foreground">Rate Limiting (requests / minute / IP)</h3>
{!configLoading && (
<div className="max-w-sm space-y-4">
<div className="space-y-2">
<label className="label" htmlFor="throttle-auth">Auth endpoints limit</label>
<input
id="throttle-auth"
type="number" min="1" className="input"
value={throttleAuthLimit}
onChange={(e) => setThrottleAuthLimit(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Login, register, OTP. Default: 5.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="throttle-strict">Strict endpoints limit</label>
<input
id="throttle-strict"
type="number" min="1" className="input"
value={throttleStrictLimit}
onChange={(e) => setThrottleStrictLimit(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Sensitive operations. Default: 20.</p>
</div>
<div className="space-y-2">
<label className="label" htmlFor="throttle-default">Default endpoints limit</label>
<input
id="throttle-default"
type="number" min="1" className="input"
value={throttleDefaultLimit}
onChange={(e) => setThrottleDefaultLimit(e.target.value)}
/>
<p className="text-xs text-muted-foreground">All other endpoints including search. Default: 100.</p>
</div>
</div>
)}
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
{configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>

View File

@@ -390,6 +390,7 @@ export const packagesApi = {
create: (data: any) => apiClient.post<any>('/packages', data),
update: (id: string, data: any) => apiClient.patch<any>(`/packages/${id}`, data),
activate: (id: string) => apiClient.patch<any>(`/packages/${id}/activate`, {}),
deactivate: (id: string) => apiClient.patch<any>(`/packages/${id}/deactivate`, {}),
remove: (id: string) => apiClient.delete(`/packages/${id}`),
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),