feat: ( permissions ) enforce specific permission keys across API and backoffice

This commit is contained in:
Abubeker Yasin
2026-09-04 15:44:28 +03:00
parent 7c7f5a7a70
commit 7a61ebcb27
69 changed files with 1061 additions and 251 deletions

View File

@@ -26,3 +26,30 @@ export const PassengerStaffStrict = (permission: string | string[]) =>
);
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
/**
* A create / edit / domain action: the narrow key, the resource's `:manage`
* umbrella, or admin.
*
* Keeping `:manage` in the array is what makes the fine-grained keys additive —
* every position already granted `<res>:manage` keeps working without being
* re-granted in IAM. Grant the narrow key *instead of* `:manage` to restrict
* someone.
*
* @PassengerWrite(PASSENGER_PERMS.schedules.create, PASSENGER_PERMS.schedules.manage)
*/
export const PassengerWrite = (narrow: string, umbrella: string) =>
PassengerStaff([narrow, umbrella, PASSENGER_PERMS.admin]);
/**
* A delete: the narrow `:delete` key or admin. **`:manage` is deliberately not
* accepted.**
*
* Every DELETE in this app was `@PassengerAdmin()` before the fine-grained keys
* existed, and most are hard cascading deletes. Letting `:manage` through here
* would silently hand deletion to `operationsManager`, `marketingManager` and
* every other role holding a `:manage` key — access they do not have today.
* So `:manage` means create + edit, never delete.
*/
export const PassengerDelete = (narrow: string) =>
PassengerStaff([narrow, PASSENGER_PERMS.admin]);

View File

@@ -145,3 +145,31 @@ export function assertPassengerPermission(
if (hasPassengerPermission(user, permissionKey)) return;
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
/** Holds at least one of the keys. Same OR semantics as `PassengerPermissionGuard`. */
export function hasAnyPassengerPermission(
user: MeLikeUser | null | undefined,
permissionKeys: string[],
): boolean {
return permissionKeys.some((key) => hasPassengerPermission(user, key));
}
/**
* The in-handler equivalent of `@PassengerStaff([...])`, for actions a decorator
* cannot see — where the destructive variant is chosen by a body field rather
* than by the route. Cancelling a schedule is the case this exists for:
* `PATCH /schedules/:id/status` carries `{ status: 'CANCELLED' }` on the same
* route as every routine transition.
*
* Pass the umbrella and admin keys alongside the narrow one, exactly as a guard
* array would, so existing grants keep working.
*/
export function assertAnyPassengerPermission(
user: MeLikeUser | null | undefined,
permissionKeys: string[],
): void {
if (hasAnyPassengerPermission(user, permissionKeys)) return;
throw new ForbiddenException(
`Missing permission. Required one of: ${permissionKeys.join(', ')}`,
);
}

View File

@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Agents')
@Controller('agents')
@@ -26,18 +27,20 @@ export class AgentsController {
@Post()
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
@PassengerWrite(PASSENGER_PERMS.agents.create, PASSENGER_PERMS.agents.manage)
createAgent(@Body() dto: CreateAgentDto) {
return this.service.createAgent(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update agent profile' })
@PassengerWrite(PASSENGER_PERMS.agents.edit, PASSENGER_PERMS.agents.manage)
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
return this.service.updateAgent(id, dto);
}
@Delete(':id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.agents.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete agent profile' })
deleteAgent(@Param('id') id: string) {

View File

@@ -34,7 +34,7 @@ import {
IssueReservationBookingDto,
} from "./guest-booking.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
import { PassengerDelete, PassengerStaff, PassengerStaffStrict, PassengerWrite } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { SeatsService } from "../seats/seats.service";
@@ -372,7 +372,7 @@ export class BookingsController {
}
@Post("group")
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
@@ -417,7 +417,14 @@ If booking creation fails after the seats were already held, every hold involved
}
@Delete("reservations/:seatId")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
// Releasing a held seat is a cancel, not a booking delete — `bookings:cancel` is the
// narrow key for it; the two `:manage` keys stay so today's holders are unaffected.
@PassengerStaff([
PASSENGER_PERMS.bookings.cancel,
PASSENGER_PERMS.seats.manage,
PASSENGER_PERMS.bookings.manage,
PASSENGER_PERMS.admin,
])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Cancel a seat's pending-payment reservation and release the seat",
@@ -703,7 +710,7 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
}
@Delete(":id")
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.bookings.delete)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
description: "Permanently deletes a booking record",

View File

@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { Currency } from '@prisma/client';
import { CurrencyService } from './currency.service';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
class CreateRateDto {
@IsEnum(Currency) fromCurrency: Currency;
@@ -30,7 +31,7 @@ export class CurrencyController {
}
@Post()
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.currencies.create, PASSENGER_PERMS.currencies.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create exchange rate' })
create(@Body() dto: CreateRateDto) {
@@ -38,7 +39,7 @@ export class CurrencyController {
}
@Patch(':id')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.currencies.edit, PASSENGER_PERMS.currencies.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update exchange rate by ID' })
update(@Param('id') id: string, @Body() dto: UpdateRateDto) {
@@ -46,7 +47,7 @@ export class CurrencyController {
}
@Delete(':id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.currencies.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete exchange rate by ID' })
delete(@Param('id') id: string) {

View File

@@ -9,7 +9,20 @@ import {
ConfirmExcessOtpDto,
} from './excess-baggage.dto';
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
/**
* Logging or re-sending a luggage charge bills a passenger and texts them a payment
* link, so it is its own grant. `bookings:manage` stays in the array as the umbrella —
* baggage hangs off a booking, and that is the key the backoffice already assumed.
*
* These two routes previously required no permission at all: the class-level
* `IamJwtGuard` is only authentication, so any signed-in account — a portal customer
* included — could raise a charge.
*/
const CanCharge = () =>
PassengerStaff([PASSENGER_PERMS.excessBaggage.charge, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]);
class UpsertBaggageAllowanceDto {
@IsString() seatClassId: string;
@@ -27,6 +40,7 @@ export class ExcessBaggageAgentController {
constructor(private service: ExcessBaggageService) {}
@Post()
@CanCharge()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
dto.agentId = req.user?.id ?? req.user?.sub ?? '';
@@ -86,6 +100,7 @@ export class ExcessBaggageAgentController {
}
@Post(':id/resend')
@CanCharge()
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fleet')
@@ -22,7 +22,7 @@ export class FleetController {
}
@Post('coach-types')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.coaches.create, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' })
@@ -31,7 +31,7 @@ export class FleetController {
}
@Patch('coach-types/:id')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto })
@@ -42,7 +42,7 @@ export class FleetController {
}
@Delete('coach-types/:id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.coaches.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@@ -62,7 +62,7 @@ export class FleetController {
}
@Post('classes')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
@@ -71,7 +71,7 @@ export class FleetController {
}
@Patch('classes/:id')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.classes.edit, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@@ -82,7 +82,7 @@ export class FleetController {
}
@Delete('classes/:id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.classes.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@@ -103,7 +103,7 @@ export class FleetController {
}
@Post('seat-classes')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
@@ -112,7 +112,7 @@ export class FleetController {
}
@Patch('seat-classes/:id')
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.classes.edit, PASSENGER_PERMS.classes.manage)
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@@ -123,7 +123,7 @@ export class FleetController {
}
@Delete('seat-classes/:id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.classes.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@@ -143,7 +143,7 @@ export class FleetController {
}
@Post('trains')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.trains.create, PASSENGER_PERMS.trains.manage)
@ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' })
@@ -152,7 +152,7 @@ export class FleetController {
}
@Patch('trains/:id')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.trains.edit, PASSENGER_PERMS.trains.manage)
@ApiOperation({ summary: 'Update a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiBody({ type: CreateTrainDto })
@@ -163,7 +163,7 @@ export class FleetController {
}
@Delete('trains/:id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.trains.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@@ -175,7 +175,7 @@ export class FleetController {
}
@Patch('trains/:id/restore')
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.trains.edit, PASSENGER_PERMS.trains.manage)
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train restored' })
@@ -229,6 +229,7 @@ export class FleetController {
}
@Get('coaches/utilization')
@PassengerStaff([PASSENGER_PERMS.reports.coachUtilization.view, PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
@ApiResponse({ status: 200, description: 'Coach utilization data' })
@@ -279,7 +280,7 @@ export class FleetController {
}
@Post('coaches')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.coaches.create, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({
@@ -305,7 +306,7 @@ export class FleetController {
}
@Patch('coaches/:id')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@@ -332,7 +333,7 @@ export class FleetController {
}
@Delete('coaches/:id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.coaches.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a coach' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@@ -344,7 +345,7 @@ export class FleetController {
}
@Post('assignments')
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'Coach assigned' })
@@ -354,7 +355,7 @@ export class FleetController {
}
@Delete('assignments/:id')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'Assignment UUID' })
@@ -365,6 +366,7 @@ export class FleetController {
}
@Post('seatmap/generate')
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
@ApiOperation({
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
description: `Generates a structured seat map for bed coaches without persisting anything.

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FraudService, FraudRuleConfig } from './fraud.service';
import { PassengerStaff } from '../../common/passenger-guards';
import { PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fraud Detection')
@@ -41,7 +41,7 @@ export class FraudController {
* Create or update fraud rule
*/
@Post('rules')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.fraud.create, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Create or update fraud rule' })
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
const rule = await this.fraudService.upsertRule(body.type, body.config);
@@ -52,7 +52,7 @@ export class FraudController {
* Acknowledge a fraud alert
*/
@Patch('alerts/:id/acknowledge')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Acknowledge a fraud alert' })
async acknowledgeAlert(@Param('id') id: string) {
const alert = await this.fraudService.acknowledgeAlert(id);
@@ -63,7 +63,7 @@ export class FraudController {
* Block user via userId
*/
@Post('users/:userId/block')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Block user by userId' })
async blockUserById(
@Param('userId') userId: string,
@@ -77,7 +77,7 @@ export class FraudController {
* Block user temporarily
*/
@Post('actions/block')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Block user temporarily' })
async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
@@ -88,7 +88,7 @@ export class FraudController {
* Unblock user
*/
@Post('actions/unblock')
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
@ApiOperation({ summary: 'Unblock user' })
async unblockUser(@Body() body: { iamUserId: string }) {
await this.fraudService.unblockUser(body.iamUserId);

View File

@@ -97,6 +97,7 @@ export class NotificationsController {
}
@Post('test')
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
async testNotification(@Body() dto: TestNotificationDto) {
return this.service.send(

View File

@@ -7,7 +7,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Packages')
@@ -36,7 +36,7 @@ export class PackagesController {
}
@Patch('inquiries/:id/status')
@PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.inquiries.edit, PASSENGER_PERMS.inquiries.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
@@ -44,7 +44,7 @@ export class PackagesController {
}
@Delete('inquiries/:id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.inquiries.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete inquiry (backoffice)' })
deleteInquiry(@Param('id') id: string) {
@@ -126,7 +126,7 @@ export class PackagesController {
}
@Post()
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.packages.create, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) {
@@ -134,7 +134,7 @@ export class PackagesController {
}
@Patch(':id')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
@@ -142,7 +142,7 @@ export class PackagesController {
}
@Delete(':id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.packages.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete package (admin)' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
@@ -151,7 +151,7 @@ export class PackagesController {
}
@Post(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
@ApiConsumes('multipart/form-data')
@@ -166,7 +166,7 @@ export class PackagesController {
}
@Delete(':id/image')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
removeImage(@Param('id') id: string) {
@@ -174,7 +174,7 @@ export class PackagesController {
}
@Patch(':id/activate')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) {
@@ -182,7 +182,7 @@ export class PackagesController {
}
@Patch(':id/deactivate')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Deactivate package (admin)' })
deactivate(@Param('id') id: string) {
@@ -190,7 +190,7 @@ export class PackagesController {
}
@Post(':id/tiers')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
@@ -198,7 +198,7 @@ export class PackagesController {
}
@Patch('tiers/:tierId')
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
@@ -206,7 +206,7 @@ export class PackagesController {
}
@Delete('tiers/:tierId')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete price tier (admin)' })
deleteTier(@Param('tierId') tierId: string) {

View File

@@ -28,7 +28,8 @@ import {
RegisterPassengerDto,
} from "./passengers.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { PassengerAdmin } from "../../common/passenger-guards";
import { PassengerDelete } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { VerifaydaService } from "../verifayda/verifayda.service";
import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard";
import { PrismaService } from "../../common/prisma.service";
@@ -566,7 +567,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
}
@Delete(":id")
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.passengers.delete)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Delete passenger (admin only)",

View File

@@ -37,7 +37,7 @@ import {
ForceConfirmDto,
ConfirmOtpDto,
} from "./payments.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PassengerDelete, PassengerStaff, PassengerWrite } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { resolveActingUser } from "../../common/acting-user";
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
@@ -85,7 +85,7 @@ export class PaymentsController {
) {}
@Delete(":id")
@PassengerStaff([PASSENGER_PERMS.admin])
@PassengerDelete(PASSENGER_PERMS.payments.delete)
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Delete a payment intent record (admin only)" })
deletePayment(@Param("id") id: string) {
@@ -252,6 +252,7 @@ export class PaymentsController {
@Post("methods")
@PassengerStaff([
PASSENGER_PERMS.paymentMethods.create,
PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
@@ -266,6 +267,7 @@ export class PaymentsController {
@Patch("methods/:id")
@PassengerStaff([
PASSENGER_PERMS.paymentMethods.edit,
PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
@@ -394,7 +396,7 @@ export class PaymentsController {
// ── Supplementary Charges ──────────────────────────────────────────────────
@Post('supplementary')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@PassengerStaff([PASSENGER_PERMS.payments.supplementary, PASSENGER_PERMS.payments.create, PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) {
@@ -504,7 +506,7 @@ export class PaymentsController {
}
@Post('supplementary/:id/mark-paid')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
markSupplementaryPaid(
@@ -515,7 +517,7 @@ export class PaymentsController {
}
@Post('supplementary/:id/waive')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
waiveSupplementaryCharge(
@@ -528,7 +530,7 @@ export class PaymentsController {
}
@Post('supplementary/:id/resend')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@PassengerStaff([PASSENGER_PERMS.payments.supplementary, PASSENGER_PERMS.payments.create, PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
resendSupplementaryLink(@Param('id') id: string) {

View File

@@ -12,26 +12,60 @@ import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReport
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
const R = PASSENGER_PERMS.reports;
/**
* One report's guard: its own key, the `reports:view` umbrella, or admin.
*
* The umbrella is kept in every array so the `finance`, `financeManager` and
* `director` presets — which hold `reports:view` — keep seeing every report.
* Granting only a per-report key hands out that report and nothing else.
*
* This is deliberately NOT a class-level decorator. Nest requires controller-
* level AND route-level guards to both pass, so a class-level
* `[reports.view, admin]` plus a per-route key would be an AND and would lock
* out everyone holding only `reports:view`.
*/
const Report = (key: string) => PassengerStaff([key, R.view, PASSENGER_PERMS.admin]);
/** The schedule picker is shared by five reports, so any report key opens it. */
const AnyReport = () =>
PassengerStaff([
R.overall.view,
R.finance.view,
R.coachUtilization.view,
R.seatStatus.view,
R.blockedSeats.view,
R.passengers.view,
R.boarding.view,
R.payments.view,
R.catalog.view,
R.view,
PASSENGER_PERMS.admin,
]);
@ApiTags("Reports")
@Controller("reports")
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
export class ReportsController {
constructor(private service: ReportsService) {}
@Post("generate")
@Report(R.catalog.view)
@ApiOperation({ summary: "Generate operational report" })
generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto);
}
@Get('schedules')
@AnyReport()
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
listSchedulesForPicker(@Query('all') all?: string) {
return this.service.listSchedulesForPicker(all === 'true');
}
@Get("passengers/list")
@Report(R.passengers.view)
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
getPassengerList(@Query("scheduleId") scheduleId: string) {
return this.service.getPassengerList(scheduleId);
@@ -39,6 +73,7 @@ export class ReportsController {
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("passengers/overview")
@Report(R.passengers.view)
@ApiOperation({
summary: "Fleet-wide passenger mix across a departure window",
description:
@@ -56,12 +91,14 @@ export class ReportsController {
}
@Get("passengers")
@Report(R.passengers.view)
@ApiOperation({ summary: "Passengers report for a specific schedule" })
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
return this.service.getOccupancyBySchedule(scheduleId);
}
@Get("payment-discrepancy")
@Report(R.payments.view)
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
getPaymentDiscrepancy(
@Query('from') from?: string,
@@ -73,6 +110,7 @@ export class ReportsController {
}
@Get("seat-status")
@Report(R.seatStatus.view)
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
return this.service.getSeatStatusReport(scheduleId);
@@ -80,6 +118,7 @@ export class ReportsController {
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
@Get("seat-status/overview")
@Report(R.seatStatus.view)
@ApiOperation({
summary: "Fleet-wide seat status across a departure window",
description:
@@ -96,18 +135,21 @@ export class ReportsController {
}
@Get("boarding")
@Report(R.boarding.view)
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
getBoardingReport(@Query('scheduleId') scheduleId: string) {
return this.service.getBoardingReport(scheduleId);
}
@Get("payments")
@Report(R.payments.view)
@ApiOperation({ summary: "Payments collected for a schedule" })
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
return this.service.getPaymentsReport(scheduleId);
}
@Get("payments/discrepancy")
@Report(R.payments.view)
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
getPaymentDiscrepancyBySchedule(
@Query('scheduleId') scheduleId: string,
@@ -121,6 +163,7 @@ export class ReportsController {
// ── Finance Summary ──────────────────────────────────────────────────────
@Get("finance")
@Report(R.finance.view)
@ApiOperation({
summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency",
description:
@@ -138,6 +181,7 @@ export class ReportsController {
}
@Get("finance/export")
@Report(R.finance.export)
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
@ApiProduces("text/csv")
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
@@ -154,6 +198,7 @@ export class ReportsController {
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
@Get("blocked-seats-revenue-loss")
@Report(R.blockedSeats.view)
@ApiOperation({
summary: "Potential revenue lost to blocked seats, per schedule",
description:
@@ -176,6 +221,7 @@ export class ReportsController {
}
@Get("blocked-seats-revenue-loss/export")
@Report(R.blockedSeats.export)
@ApiOperation({
summary: "Blocked-seat revenue loss as CSV",
description:
@@ -200,12 +246,14 @@ export class ReportsController {
}
@Get(":reportId")
@Report(R.catalog.view)
@ApiOperation({ summary: "Get report by ID" })
getReport(@Param("reportId") reportId: string) {
return this.service.getReport(reportId);
}
@Get()
@Report(R.catalog.view)
@ApiOperation({ summary: "List reports" })
listReports(@Query("type") type?: string) {
return this.service.listReports(type);

View File

@@ -1,7 +1,7 @@
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 { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { RescheduleService } from './reschedule.service';
import {
@@ -17,7 +17,7 @@ export class RescheduleController {
constructor(private service: RescheduleService) {}
@Get('reschedule/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
listPolicies() {
@@ -25,7 +25,7 @@ export class RescheduleController {
}
@Get('reschedule/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() {
@@ -33,7 +33,7 @@ export class RescheduleController {
}
@Post('reschedule/policies')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.reschedulePolicies.create, PASSENGER_PERMS.reschedulePolicies.manage)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
@@ -41,7 +41,7 @@ export class RescheduleController {
}
@Patch('reschedule/policies/:coachTypeId')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.reschedulePolicies.edit, PASSENGER_PERMS.reschedulePolicies.manage)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
@@ -49,7 +49,7 @@ export class RescheduleController {
}
@Delete('reschedule/policies/:coachTypeId')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.reschedulePolicies.delete)
@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) {

View File

@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Routes')
@@ -13,7 +13,7 @@ export class RoutesController {
// ── Routes ─────────────────────────────────────────────────────────────────
@Post()
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(PASSENGER_PERMS.routes.create, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Create a reusable route with its ordered stops',
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
@@ -41,7 +41,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
@Patch(':id')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' })
@@ -49,7 +49,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
@Delete(':id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.routes.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@@ -68,7 +68,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Post(':id/stops')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add a stop to an existing route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 201, description: 'Stop added' })
@@ -77,7 +77,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
@Delete(':id/stops/:sequence')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@@ -108,7 +108,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches')
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Set the default coach lineup for this route',
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
@@ -122,7 +122,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
}
@Delete(':id/coaches')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
@ApiParam({ name: 'id', description: 'Route UUID' })

View File

@@ -1,25 +1,39 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { assertAnyPassengerPermission } from '../../common/passenger-permission.util';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
const P = PASSENGER_PERMS;
const S = PASSENGER_PERMS.schedules;
const F = PASSENGER_PERMS.scheduleFares;
/**
* Fare rules live under /schedules but are a separate grant: editing a timetable
* and changing a price are different jobs. `schedules.manage` stays in the array
* so whoever edits fares today is not locked out the day this ships.
*/
const FareWrite = (narrow: string) =>
PassengerStaff([narrow, F.manage, S.manage, P.admin]);
const FareDelete = () => PassengerStaff([F.delete, F.manage, S.manage, P.admin]);
@ApiTags('Schedule')
@Controller('schedules')
export class SchedulesController {
constructor(private service: SchedulesService) {}
@Post('bulk-generate')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(S.create, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto);
}
@Post()
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(S.create, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a train schedule from a route template' })
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
@@ -42,13 +56,13 @@ export class SchedulesController {
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@FareWrite(F.create) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Patch('fares/:id')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' })
@ApiResponse({ status: 200, description: 'Fare rule updated' })
@@ -57,7 +71,7 @@ export class SchedulesController {
}
@Delete('fares/:id')
@PassengerAdmin()
@FareDelete()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' })
@@ -65,13 +79,13 @@ export class SchedulesController {
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
@Post('segment-fares')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@FareWrite(F.create) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
// Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
@Delete('routes/fare-rules/:id')
@PassengerAdmin()
@FareDelete()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
@@ -80,7 +94,7 @@ export class SchedulesController {
}
@Patch('routes/fare-rules/:id')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a route-level fare override' })
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
@@ -96,7 +110,7 @@ export class SchedulesController {
}
@Post('routes/:routeId/fare-rules')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@FareWrite(F.create) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a route-level fare override' })
@ApiParam({ name: 'routeId', description: 'Route UUID' })
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
@@ -110,13 +124,13 @@ export class SchedulesController {
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@Delete('segment-fares/:id')
@PassengerAdmin()
@FareDelete()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
@@ -131,7 +145,7 @@ export class SchedulesController {
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':id')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a schedule (partial)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
@@ -139,15 +153,33 @@ export class SchedulesController {
}
@Patch(':id/status')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update schedule status' })
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Update schedule status',
description:
'Routine transitions need `schedules:edit`. Moving a schedule to CANCELLED additionally ' +
'needs `schedules:cancel` — cancelling strands every booked passenger, so it is a separate ' +
'grant from editing a timetable.',
})
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
@ApiResponse({ status: 403, description: 'Cancelling without `schedules:cancel`' })
updateStatus(
@Param('id') id: string,
@Body() dto: UpdateScheduleStatusDto,
@Req() req: { user?: unknown },
) {
// The guard cannot see the body — CANCELLED arrives on the same route as
// BOARDING or DELAYED — so the narrower check happens here. `schedules.manage`
// is in the list, so a manage holder cancels exactly as they do today; an
// `edit`-only holder can retime a trip but not cancel it.
if (dto.status === TripStatus.CANCELLED) {
assertAnyPassengerPermission(req.user as never, [S.cancel, S.manage, P.admin]);
}
return this.service.updateScheduleStatus(id, dto);
}
@Delete(':id')
@PassengerAdmin()
@PassengerDelete(S.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@@ -155,7 +187,7 @@ export class SchedulesController {
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
@Post(':id/recalculate-stops')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); }
@@ -167,7 +199,7 @@ export class SchedulesController {
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Patch(':id/stops/:sequence')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a stop time' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
@@ -178,7 +210,7 @@ export class SchedulesController {
) { return this.service.updateStop(id, sequence, dto); }
@Post(':id/delay')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
@@ -194,7 +226,7 @@ records the accumulated delay on the schedule's live status. Does not change sch
}
@Put(':scheduleId/fares/:seatClassId')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Override fare for a specific seat class on a schedule',
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
@@ -242,13 +274,13 @@ records the accumulated delay on the schedule's live status. Does not change sch
}
@Post(':id/fares/sync')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Sync fares from fare engine' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
@Post(':id/coaches')
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Assign coaches to a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
assignCoaches(
@@ -264,7 +296,7 @@ records the accumulated delay on the schedule's live status. Does not change sch
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
@Delete(':id/coaches/:coachId')
@PassengerAdmin()
@PassengerWrite(S.edit, S.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Remove a coach assignment' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody }
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Seat Classes')
@@ -26,7 +26,7 @@ export class SeatClassesController {
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
@Post()
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(PASSENGER_PERMS.tariffRates.create, PASSENGER_PERMS.tariffRates.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a seat class' })
@ApiBody({ type: CreateSeatClassDto })
@ApiResponse({ status: 201, description: 'Seat class created' })
@@ -34,7 +34,7 @@ export class SeatClassesController {
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
@Patch(':id')
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@PassengerWrite(PASSENGER_PERMS.tariffRates.edit, PASSENGER_PERMS.tariffRates.manage) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiBody({ type: UpdateSeatClassDto })
@@ -43,7 +43,7 @@ export class SeatClassesController {
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
@Delete(':id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.tariffRates.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' })

View File

@@ -25,7 +25,7 @@ import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaint
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { PassengerStaff } from "../../common/passenger-guards";
import { PassengerDelete, PassengerWrite } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Seats")
@@ -35,7 +35,7 @@ export class SeatsController {
// ── Blocked Seats ─────────────────────────────────────────────────────────
@Get('blocks')
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
@ApiResponse({ status: 200, description: 'Blocked seat records' })
@@ -187,7 +187,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Post("auto-assign-hold")
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
@@ -247,7 +247,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(":seatId/block")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Block a seat (e.g., maintenance, damage)",
@@ -268,7 +268,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
}
@Delete(":seatId/block")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Unblock a seat" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -279,7 +279,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Maintenance ───────────────────────────────────────────────────────────
@Post(":seatId/maintenance")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -294,7 +294,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
}
@Delete(":seatId/maintenance")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Clear seat maintenance status" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -305,7 +305,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(":seatId/remove")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@PassengerDelete(PASSENGER_PERMS.seats.delete)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Remove a seat by marking with negative seatNumber",
@@ -321,7 +321,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
}
@Patch(":seatId/undo-remove")
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Undo seat removal by restoring original seatNumber",
@@ -338,7 +338,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
}
@Get("export/csv/:scheduleId")
@UseGuards(JwtGuard)
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Export seats as CSV" })
async exportCSV(@Param("scheduleId") scheduleId: string) {
@@ -347,7 +347,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
}
@Post("import/preview")
@UseGuards(JwtGuard)
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Preview CSV import" })
previewCSV(@Body() body: { csv: string }) {
@@ -355,7 +355,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
}
@Post("import/commit")
@UseGuards(JwtGuard)
@PassengerWrite(PASSENGER_PERMS.seats.create, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Commit CSV import" })
importCSV(
@@ -367,7 +367,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
// ── Duplicate seat management (backoffice) ────────────────────────────────
@Get("duplicates")
@UseGuards(JwtGuard)
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: "List duplicate seat assignments by schedule date",
@@ -418,7 +418,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
}
@Post("duplicates/resolve")
@UseGuards(JwtGuard)
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
@ApiBearerAuth("JWT-auth")
@ApiOperation({
summary: "Auto-assign duplicate bookings to seats in selected coaches",

View File

@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@ne
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Stations')
@@ -79,7 +79,7 @@ export class StationsController {
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.stations.create, PASSENGER_PERMS.stations.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create new station' })
@ApiResponse({
@@ -105,7 +105,7 @@ export class StationsController {
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id')
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
@PassengerWrite(PASSENGER_PERMS.stations.edit, PASSENGER_PERMS.stations.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update station' })
@ApiResponse({
@@ -134,7 +134,7 @@ export class StationsController {
}
@Delete(':id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.stations.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete station' })
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
import { PassengerStaff, PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { resolveActingUser } from '../../common/acting-user';
@@ -118,7 +118,7 @@ export class TicketsController {
}
@Post('scan-board/:qrCodeOrRef')
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
@PassengerWrite(PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Scan QR code or booking ref and automatically board ticket',
@@ -146,7 +146,7 @@ export class TicketsController {
}
@Post(':bookingRef/validate')
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
@PassengerStaff([PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Validate ticket at gate with audit logging',
@@ -194,7 +194,7 @@ export class TicketsController {
}
@Post('validate/offline')
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
@PassengerStaff([PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Batch import offline validations',
@@ -226,7 +226,7 @@ export class TicketsController {
}
@Delete(':id')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.tickets.delete)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Delete ticket (admin only)',
@@ -237,7 +237,7 @@ export class TicketsController {
}
@Patch(':id/restore')
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
@PassengerWrite(PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage)
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
restore(@Param('id') id: string) {

View File

@@ -1,7 +1,7 @@
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 { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { UpgradeService } from './upgrade.service';
import {
@@ -18,7 +18,7 @@ export class UpgradeController {
constructor(private service: UpgradeService) {}
@Get('upgrade/policies')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
listPolicies() {
@@ -26,7 +26,7 @@ export class UpgradeController {
}
@Get('upgrade/policies/available-coach-types')
@PassengerStaff(PASSENGER_PERMS.bookings.view)
@PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
listUnconfiguredCoachTypes() {
@@ -34,7 +34,7 @@ export class UpgradeController {
}
@Post('upgrade/policies')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.upgradePolicies.create, PASSENGER_PERMS.upgradePolicies.manage)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
@@ -42,7 +42,7 @@ export class UpgradeController {
}
@Patch('upgrade/policies/:coachTypeId')
@PassengerAdmin()
@PassengerWrite(PASSENGER_PERMS.upgradePolicies.edit, PASSENGER_PERMS.upgradePolicies.manage)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
@@ -50,7 +50,7 @@ export class UpgradeController {
}
@Delete('upgrade/policies/:coachTypeId')
@PassengerAdmin()
@PassengerDelete(PASSENGER_PERMS.upgradePolicies.delete)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {

View File

@@ -128,6 +128,9 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('2ce6d0e3-3426-4424-a239-3ddf95b001c4', 'edr_passenger_app:schedules:cancel', 'Cancel schedules'),
perm('045d3f55-d5fc-4ef4-8d4f-949496b77c25', 'edr_passenger_app:seats:block', 'Block and unblock seats'),
perm('8ac8f5de-26b0-469b-993e-2abfa005d223', 'edr_passenger_app:packages:publish', 'Activate and deactivate packages'),
perm('4c263527-9868-4ec6-8861-039cd5e37948', 'edr_passenger_app:tickets:board', 'Board passengers'),
perm('635a92ec-def7-4373-a076-5437ccac09b8', 'edr_passenger_app:payments:supplementary', 'Send supplementary payment'),
perm('7f6deaf2-37c9-4420-ab29-9278266e3a5d', 'edr_passenger_app:excess_baggage:charge', 'Send excess luggage payment'),
// ── Schedule fares — split out of `schedules:manage` ────────────────────────────
perm('7e7406b3-77dc-422a-b5de-a8286f973b38', 'edr_passenger_app:schedule_fares:view', 'View schedule fares'),
@@ -149,6 +152,19 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('2a8fb77e-4bf6-42e0-80ca-53ca142b1b23', 'edr_passenger_app:reports_payments:view', 'View payments report'),
perm('3476bbe6-47f5-4040-8b3c-88b417b1c0c2', 'edr_passenger_app:reports_catalog:view', 'View generated report catalog'),
// ── Reschedule & upgrade policies ────────────────────────────────────────────
perm('9d27002e-7ee1-445a-b732-347f3b18d89a', 'edr_passenger_app:reschedule_policies:view', 'View reschedule policies'),
perm('19b50492-8915-4a05-8c15-61b39fe244d1', 'edr_passenger_app:reschedule_policies:manage', 'Manage reschedule policies'),
perm('9955afb1-61ee-4dd2-a863-b85631de730b', 'edr_passenger_app:reschedule_policies:create', 'Create reschedule policies'),
perm('c7d0e2e2-7894-40ad-95af-a91740658ae4', 'edr_passenger_app:reschedule_policies:edit', 'Edit reschedule policies'),
perm('a45a796e-4fd2-46b0-8d0a-1f2aa227763a', 'edr_passenger_app:reschedule_policies:delete', 'Delete reschedule policies'),
perm('3f088266-af6a-49b8-bfc6-287e8b459022', 'edr_passenger_app:upgrade_policies:view', 'View upgrade policies'),
perm('678363f4-cf2e-4488-80ad-9ed8b7760d55', 'edr_passenger_app:upgrade_policies:manage', 'Manage upgrade policies'),
perm('1383de71-b308-4fc4-9900-6f045d8a74d0', 'edr_passenger_app:upgrade_policies:create', 'Create upgrade policies'),
perm('c2842698-0998-4675-ba28-8cbc4e9ac1a8', 'edr_passenger_app:upgrade_policies:edit', 'Edit upgrade policies'),
perm('f43ef123-65bd-49c3-b5c2-c943f5f4aaf2', 'edr_passenger_app:upgrade_policies:delete', 'Delete upgrade policies'),
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
];
@@ -164,6 +180,25 @@ export const PASSENGER_PERMS = {
edit: 'edr_passenger_app:bookings:edit',
delete: 'edr_passenger_app:bookings:delete',
},
/**
* Fee/window policy for changing a confirmed booking. Its own resource rather than a
* booking action: an agent who can manage bookings must not be able to rewrite the fee
* schedule those bookings are priced against.
*/
reschedulePolicies: {
view: 'edr_passenger_app:reschedule_policies:view',
manage: 'edr_passenger_app:reschedule_policies:manage',
create: 'edr_passenger_app:reschedule_policies:create',
edit: 'edr_passenger_app:reschedule_policies:edit',
delete: 'edr_passenger_app:reschedule_policies:delete',
},
upgradePolicies: {
view: 'edr_passenger_app:upgrade_policies:view',
manage: 'edr_passenger_app:upgrade_policies:manage',
create: 'edr_passenger_app:upgrade_policies:create',
edit: 'edr_passenger_app:upgrade_policies:edit',
delete: 'edr_passenger_app:upgrade_policies:delete',
},
passengers: {
view: 'edr_passenger_app:passengers:view',
manage: 'edr_passenger_app:passengers:manage',
@@ -175,6 +210,8 @@ export const PASSENGER_PERMS = {
view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage',
generate: 'edr_passenger_app:tickets:generate',
/** Marking a passenger boarded — gate scanning separately from editing a ticket. */
board: 'edr_passenger_app:tickets:board',
create: 'edr_passenger_app:tickets:create',
edit: 'edr_passenger_app:tickets:edit',
delete: 'edr_passenger_app:tickets:delete',
@@ -183,6 +220,8 @@ export const PASSENGER_PERMS = {
view: 'edr_passenger_app:payments:view',
manage: 'edr_passenger_app:payments:manage',
create: 'edr_passenger_app:payments:create',
/** Raising and re-sending a supplementary charge — bills a passenger and sends a pay link. */
supplementary: 'edr_passenger_app:payments:supplementary',
edit: 'edr_passenger_app:payments:edit',
delete: 'edr_passenger_app:payments:delete',
// legacy keys — retained as aliases for backward compatibility
@@ -190,6 +229,13 @@ export const PASSENGER_PERMS = {
refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods',
},
/**
* Excess luggage. Only the charge action is modelled: logging one bills the passenger
* and sends them a payment link, which is the part worth granting separately.
*/
excessBaggage: {
charge: 'edr_passenger_app:excess_baggage:charge',
},
paymentMethods: {
view: 'edr_passenger_app:payment_methods:view',
manage: 'edr_passenger_app:payment_methods:manage',