mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Backoffice portal updates: dashboard, seat management, pricing, audit logging, reporting
This commit is contained in:
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { PrismaModule } from './common/prisma.module';
|
||||
import { AuditModule } from './common/audit.module';
|
||||
import { I18nModule } from './common/i18n/i18n.module';
|
||||
import { IamModule } from './common/iam.module';
|
||||
import { LocaleMiddleware } from './common/i18n/locale.middleware';
|
||||
@@ -38,6 +39,7 @@ import { FraudModule } from './modules/fraud/fraud.module';
|
||||
import { SeatClassesModule } from './modules/seat-classes/seat-classes.module';
|
||||
import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
import { AuditModuleFeature } from './modules/audit/audit.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -57,6 +59,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
PrismaModule,
|
||||
AuditModule,
|
||||
I18nModule,
|
||||
IamModule,
|
||||
AuthModule,
|
||||
@@ -83,6 +86,7 @@ import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
SeatClassesModule,
|
||||
FareEngineModule,
|
||||
VerifaydaModule,
|
||||
AuditModuleFeature,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/common/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma.module';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
92
apps/edr-passenger-api/src/common/audit.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Injectable, Inject, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
) {}
|
||||
|
||||
async log(input: {
|
||||
userId?: string;
|
||||
action: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT' | 'VERIFY' | string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
oldData?: any;
|
||||
newData?: any;
|
||||
}) {
|
||||
try {
|
||||
const ipAddress = this.getIpAddress();
|
||||
const userAgent = this.getUserAgent();
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
oldData: input.oldData,
|
||||
newData: input.newData,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log audit event:', error);
|
||||
// Don't throw - audit logging should not break main operations
|
||||
}
|
||||
}
|
||||
|
||||
private getIpAddress(): string {
|
||||
if (!this.request) return '';
|
||||
|
||||
return (
|
||||
this.request.headers['x-forwarded-for']?.split(',')[0].trim() ||
|
||||
this.request.headers['x-real-ip'] ||
|
||||
this.request.connection?.remoteAddress ||
|
||||
this.request.socket?.remoteAddress ||
|
||||
this.request.ip ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private getUserAgent(): string {
|
||||
return this.request?.headers?.['user-agent'] || '';
|
||||
}
|
||||
|
||||
async getLogs(filters: any = {}) {
|
||||
const where: any = {};
|
||||
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ entityId: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ user: { email: { contains: filters.search, mode: 'insensitive' } } },
|
||||
{ user: { fullName: { contains: filters.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (filters.action) {
|
||||
where.action = filters.action;
|
||||
}
|
||||
|
||||
if (filters.entityType) {
|
||||
where.entityType = filters.entityType;
|
||||
}
|
||||
|
||||
return this.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 500, // Limit to last 500 logs
|
||||
});
|
||||
}
|
||||
|
||||
async getLog(id: string) {
|
||||
return this.prisma.auditLog.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
41
apps/edr-passenger-api/src/modules/audit/audit.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@Controller('audit')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class AuditController {
|
||||
constructor(private auditService: AuditService) {}
|
||||
|
||||
@Get('logs')
|
||||
@ApiOperation({
|
||||
summary: 'Get audit logs',
|
||||
description: 'Retrieve system audit logs with optional filtering',
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by user email or entity ID' })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (CREATE, UPDATE, DELETE, etc.)' })
|
||||
@ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (Booking, Station, etc.)' })
|
||||
async getLogs(
|
||||
@Query('search') search?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('entityType') entityType?: string,
|
||||
) {
|
||||
const filters = {
|
||||
search: search || undefined,
|
||||
action: action || undefined,
|
||||
entityType: entityType || undefined,
|
||||
};
|
||||
|
||||
const items = await this.auditService.getLogs(filters);
|
||||
return { items };
|
||||
}
|
||||
|
||||
@Get('logs/:id')
|
||||
@ApiOperation({ summary: 'Get audit log by ID' })
|
||||
async getLog(@Param('id') id: string) {
|
||||
return this.auditService.getLog(id);
|
||||
}
|
||||
}
|
||||
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
10
apps/edr-passenger-api/src/modules/audit/audit.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { AuditController } from './audit.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, HttpModule],
|
||||
controllers: [AuditController],
|
||||
})
|
||||
export class AuditModuleFeature {}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { GuestBookingService } from './guest-booking.service';
|
||||
@@ -8,7 +9,7 @@ import { VerifaydaModule } from '../verifayda/verifayda.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({
|
||||
imports: [SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, HttpModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, GuestBookingService],
|
||||
exports: [BookingsService, GuestBookingService]
|
||||
|
||||
@@ -8,7 +8,10 @@ export class ReportsService {
|
||||
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
const dateFrom = new Date(dto.dateFrom);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
const dateTo = new Date(dto.dateTo);
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
let data: any;
|
||||
switch (dto.reportType) {
|
||||
@@ -44,14 +47,16 @@ export class ReportsService {
|
||||
}
|
||||
|
||||
private async generateRevenueReport(dateFrom: Date, dateTo: Date) {
|
||||
// Fetch all bookings in date range, regardless of status
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
status: { in: ['CONFIRMED', 'COMPLETED'] }
|
||||
createdAt: { gte: dateFrom, lte: dateTo }
|
||||
},
|
||||
include: { paymentIntent: true }
|
||||
});
|
||||
|
||||
console.log(`[Reports] Revenue Report: Found ${bookings.length} bookings between ${dateFrom} and ${dateTo}`);
|
||||
|
||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||
@@ -59,12 +64,25 @@ export class ReportsService {
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Group by date for charts
|
||||
const byDate = bookings.reduce((acc, b) => {
|
||||
const date = b.createdAt.toISOString().split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
acc[date].totalMinor += b.totalMinor;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
totalRevenueMinor: totalRevenue,
|
||||
totalRevenue: totalRevenue / 100,
|
||||
currency: 'ETB',
|
||||
byPaymentMethod
|
||||
byPaymentMethod,
|
||||
byDate,
|
||||
cancellationRate: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,7 +91,7 @@ export class ReportsService {
|
||||
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
||||
include: {
|
||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||
bookings: { where: { status: { in: ['CONFIRMED', 'COMPLETED'] } }, include: { seats: true } },
|
||||
bookings: { include: { seats: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { StationsController } from './stations.controller';
|
||||
import { StationsService } from './stations.service';
|
||||
|
||||
@Module({ controllers: [StationsController], providers: [StationsService], exports: [StationsService] })
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [StationsController],
|
||||
providers: [StationsService],
|
||||
exports: [StationsService],
|
||||
})
|
||||
export class StationsModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, Inject, Optional } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { CreateStationDto } from './stations.dto';
|
||||
|
||||
interface StationFilters {
|
||||
@@ -10,7 +12,11 @@ interface StationFilters {
|
||||
|
||||
@Injectable()
|
||||
export class StationsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
@Optional() @Inject(REQUEST) private request?: any,
|
||||
) {}
|
||||
|
||||
findAll(filters: StationFilters = {}) {
|
||||
const where: any = {};
|
||||
@@ -43,20 +49,51 @@ export class StationsService {
|
||||
return s;
|
||||
}
|
||||
|
||||
create(dto: CreateStationDto) {
|
||||
return this.prisma.station.create({ data: dto });
|
||||
async create(dto: CreateStationDto) {
|
||||
const station = await this.prisma.station.create({ data: dto });
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'Station',
|
||||
entityId: station.id,
|
||||
newData: station,
|
||||
});
|
||||
|
||||
return station;
|
||||
}
|
||||
|
||||
async update(id: string, dto: Partial<CreateStationDto>) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.update({
|
||||
where: { id },
|
||||
data: dto
|
||||
const oldStation = await this.findOne(id);
|
||||
const updatedStation = await this.prisma.station.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
});
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'Station',
|
||||
entityId: id,
|
||||
oldData: oldStation,
|
||||
newData: updatedStation,
|
||||
});
|
||||
|
||||
return updatedStation;
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id); // Check if exists
|
||||
return this.prisma.station.delete({ where: { id } });
|
||||
const station = await this.findOne(id);
|
||||
const deleted = await this.prisma.station.delete({ where: { id } });
|
||||
|
||||
await this.auditService.log({
|
||||
userId: this.request?.user?.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'Station',
|
||||
entityId: id,
|
||||
oldData: station,
|
||||
});
|
||||
|
||||
return deleted;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user