From eefbe6e8ed9ecf38aed5e8484f8051e3e05e9210 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 26 Jun 2026 12:17:01 +0300 Subject: [PATCH] Throttling configuration from the backoffice added --- apps/edr-passenger-api/src/app.module.ts | 6 ++- .../src/common/dynamic-throttler.guard.ts | 34 ++++++++++++++ .../modules/packages/packages.controller.ts | 42 +++++++++++------- .../src/modules/packages/packages.service.ts | 9 +++- .../system-config/system-config.service.ts | 12 +++++ .../backoffice/src/app/packages/page.tsx | 25 ++++++++++- .../backoffice/src/app/settings/page.tsx | 44 +++++++++++++++++++ .../backoffice/src/lib/api/index.ts | 1 + 8 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 9e13ad2f9..938450770 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -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, ], diff --git a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts new file mode 100644 index 000000000..43ddecddf --- /dev/null +++ b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts @@ -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 { + 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); + } +} diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index 6e6551030..d7eb79ff0 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -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) { 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); diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 4a320d3b8..943ffd778 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -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'); diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts index e038bc0f2..1661d2027 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.service.ts @@ -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 = { [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() diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index 70a269549..3ff0f9814 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -34,6 +34,7 @@ export default function PackagesPage() { const [editingId, setEditingId] = useState(null); const [viewPackage, setViewPackage] = useState(null); const [activateConfirm, setActivateConfirm] = useState(null); + const [deactivateConfirm, setDeactivateConfirm] = useState(null); const [tiersPackage, setTiersPackage] = useState(null); const [editingTier, setEditingTier] = useState(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 */} + 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 */} { setTiersPackage(null); setEditingTier(null); setTierError(null); }} title={`Price Tiers — ${tiersPackage?.name ?? ''}`} size="lg"> {tiersPackage && ( diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 97091a04b..82e9a00d5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -10,6 +10,9 @@ export default function SettingsPage() { const [activeTab, setActiveTab] = useState('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' && (
+

Rate Limiting (requests / minute / IP)

+ {!configLoading && ( +
+
+ + setThrottleAuthLimit(e.target.value)} + /> +

Login, register, OTP. Default: 5.

+
+
+ + setThrottleStrictLimit(e.target.value)} + /> +

Sensitive operations. Default: 20.

+
+
+ + setThrottleDefaultLimit(e.target.value)} + /> +

All other endpoints including search. Default: 100.

+
+
+ )}

Seat Booking

{configLoading ? (

Loading...

diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index f49fe4626..aff2c78e1 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -390,6 +390,7 @@ export const packagesApi = { create: (data: any) => apiClient.post('/packages', data), update: (id: string, data: any) => apiClient.patch(`/packages/${id}`, data), activate: (id: string) => apiClient.patch(`/packages/${id}/activate`, {}), + deactivate: (id: string) => apiClient.patch(`/packages/${id}/deactivate`, {}), remove: (id: string) => apiClient.delete(`/packages/${id}`), addTier: (packageId: string, data: any) => apiClient.post(`/packages/${packageId}/tiers`, data), updateTier: (tierId: string, data: any) => apiClient.patch(`/packages/tiers/${tierId}`, data),