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

@@ -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);