feature phase 3
This commit is contained in:
parent
07258e5adb
commit
c5c15eb1f9
1100
apps/backend/package-lock.json
generated
1100
apps/backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -27,14 +27,17 @@
|
|||||||
"@aws-sdk/client-s3": "^3.906.0",
|
"@aws-sdk/client-s3": "^3.906.0",
|
||||||
"@aws-sdk/lib-storage": "^3.906.0",
|
"@aws-sdk/lib-storage": "^3.906.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.906.0",
|
"@aws-sdk/s3-request-presigner": "^3.906.0",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^10.2.10",
|
"@nestjs/common": "^10.2.10",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.2.10",
|
"@nestjs/core": "^10.2.10",
|
||||||
"@nestjs/jwt": "^10.2.0",
|
"@nestjs/jwt": "^10.2.0",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.2.10",
|
"@nestjs/platform-express": "^10.2.10",
|
||||||
|
"@nestjs/platform-socket.io": "^10.4.20",
|
||||||
"@nestjs/swagger": "^7.1.16",
|
"@nestjs/swagger": "^7.1.16",
|
||||||
"@nestjs/typeorm": "^10.0.1",
|
"@nestjs/typeorm": "^10.0.1",
|
||||||
|
"@nestjs/websockets": "^10.4.20",
|
||||||
"@types/mjml": "^4.7.4",
|
"@types/mjml": "^4.7.4",
|
||||||
"@types/nodemailer": "^7.0.2",
|
"@types/nodemailer": "^7.0.2",
|
||||||
"@types/opossum": "^8.1.9",
|
"@types/opossum": "^8.1.9",
|
||||||
@ -44,6 +47,7 @@
|
|||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"ioredis": "^5.8.1",
|
"ioredis": "^5.8.1",
|
||||||
@ -63,6 +67,7 @@
|
|||||||
"pino-pretty": "^10.3.0",
|
"pino-pretty": "^10.3.0",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
"typeorm": "^0.3.17"
|
"typeorm": "^0.3.17"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -12,6 +12,9 @@ import { BookingsModule } from './application/bookings/bookings.module';
|
|||||||
import { OrganizationsModule } from './application/organizations/organizations.module';
|
import { OrganizationsModule } from './application/organizations/organizations.module';
|
||||||
import { UsersModule } from './application/users/users.module';
|
import { UsersModule } from './application/users/users.module';
|
||||||
import { DashboardModule } from './application/dashboard/dashboard.module';
|
import { DashboardModule } from './application/dashboard/dashboard.module';
|
||||||
|
import { AuditModule } from './application/audit/audit.module';
|
||||||
|
import { NotificationsModule } from './application/notifications/notifications.module';
|
||||||
|
import { WebhooksModule } from './application/webhooks/webhooks.module';
|
||||||
import { CacheModule } from './infrastructure/cache/cache.module';
|
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||||
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
||||||
|
|
||||||
@ -90,6 +93,9 @@ import { JwtAuthGuard } from './application/guards/jwt-auth.guard';
|
|||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
|
AuditModule,
|
||||||
|
NotificationsModule,
|
||||||
|
WebhooksModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
27
apps/backend/src/application/audit/audit.module.ts
Normal file
27
apps/backend/src/application/audit/audit.module.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Audit Module
|
||||||
|
*
|
||||||
|
* Provides audit logging functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuditController } from '../controllers/audit.controller';
|
||||||
|
import { AuditService } from '../services/audit.service';
|
||||||
|
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
||||||
|
import { TypeOrmAuditLogRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-audit-log.repository';
|
||||||
|
import { AUDIT_LOG_REPOSITORY } from '../../domain/ports/out/audit-log.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AuditLogOrmEntity])],
|
||||||
|
controllers: [AuditController],
|
||||||
|
providers: [
|
||||||
|
AuditService,
|
||||||
|
{
|
||||||
|
provide: AUDIT_LOG_REPOSITORY,
|
||||||
|
useClass: TypeOrmAuditLogRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [AuditService],
|
||||||
|
})
|
||||||
|
export class AuditModule {}
|
||||||
@ -19,11 +19,16 @@ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities
|
|||||||
// Import services and domain
|
// Import services and domain
|
||||||
import { BookingService } from '../../domain/services/booking.service';
|
import { BookingService } from '../../domain/services/booking.service';
|
||||||
import { BookingAutomationService } from '../services/booking-automation.service';
|
import { BookingAutomationService } from '../services/booking-automation.service';
|
||||||
|
import { ExportService } from '../services/export.service';
|
||||||
|
import { FuzzySearchService } from '../services/fuzzy-search.service';
|
||||||
|
|
||||||
// Import infrastructure modules
|
// Import infrastructure modules
|
||||||
import { EmailModule } from '../../infrastructure/email/email.module';
|
import { EmailModule } from '../../infrastructure/email/email.module';
|
||||||
import { PdfModule } from '../../infrastructure/pdf/pdf.module';
|
import { PdfModule } from '../../infrastructure/pdf/pdf.module';
|
||||||
import { StorageModule } from '../../infrastructure/storage/storage.module';
|
import { StorageModule } from '../../infrastructure/storage/storage.module';
|
||||||
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { WebhooksModule } from '../webhooks/webhooks.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookings Module
|
* Bookings Module
|
||||||
@ -46,11 +51,16 @@ import { StorageModule } from '../../infrastructure/storage/storage.module';
|
|||||||
EmailModule,
|
EmailModule,
|
||||||
PdfModule,
|
PdfModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
|
AuditModule,
|
||||||
|
NotificationsModule,
|
||||||
|
WebhooksModule,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [
|
providers: [
|
||||||
BookingService,
|
BookingService,
|
||||||
BookingAutomationService,
|
BookingAutomationService,
|
||||||
|
ExportService,
|
||||||
|
FuzzySearchService,
|
||||||
{
|
{
|
||||||
provide: BOOKING_REPOSITORY,
|
provide: BOOKING_REPOSITORY,
|
||||||
useClass: TypeOrmBookingRepository,
|
useClass: TypeOrmBookingRepository,
|
||||||
|
|||||||
218
apps/backend/src/application/controllers/audit.controller.ts
Normal file
218
apps/backend/src/application/controllers/audit.controller.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Audit Log Controller
|
||||||
|
*
|
||||||
|
* Provides endpoints for querying audit logs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseIntPipe,
|
||||||
|
DefaultValuePipe,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { AuditService } from '../services/audit.service';
|
||||||
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
|
import { AuditLog, AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity';
|
||||||
|
|
||||||
|
class AuditLogResponseDto {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
status: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
organizationId: string;
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
resourceName?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuditLogQueryDto {
|
||||||
|
userId?: string;
|
||||||
|
action?: AuditAction[];
|
||||||
|
status?: AuditStatus[];
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Audit Logs')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('api/v1/audit-logs')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class AuditController {
|
||||||
|
constructor(private readonly auditService: AuditService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs with filters
|
||||||
|
* Only admins and managers can view audit logs
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Get audit logs with filters' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Audit logs retrieved successfully' })
|
||||||
|
@ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' })
|
||||||
|
@ApiQuery({ name: 'action', required: false, description: 'Filter by action (comma-separated)', isArray: true })
|
||||||
|
@ApiQuery({ name: 'status', required: false, description: 'Filter by status (comma-separated)', isArray: true })
|
||||||
|
@ApiQuery({ name: 'resourceType', required: false, description: 'Filter by resource type' })
|
||||||
|
@ApiQuery({ name: 'resourceId', required: false, description: 'Filter by resource ID' })
|
||||||
|
@ApiQuery({ name: 'startDate', required: false, description: 'Filter by start date (ISO 8601)' })
|
||||||
|
@ApiQuery({ name: 'endDate', required: false, description: 'Filter by end date (ISO 8601)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, description: 'Items per page (default: 50)' })
|
||||||
|
async getAuditLogs(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Query('userId') userId?: string,
|
||||||
|
@Query('action') action?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('resourceType') resourceType?: string,
|
||||||
|
@Query('resourceId') resourceId?: string,
|
||||||
|
@Query('startDate') startDate?: string,
|
||||||
|
@Query('endDate') endDate?: string,
|
||||||
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
||||||
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
||||||
|
): Promise<{ logs: AuditLogResponseDto[]; total: number; page: number; pageSize: number }> {
|
||||||
|
page = page || 1;
|
||||||
|
limit = limit || 50;
|
||||||
|
const filters: any = {
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
userId,
|
||||||
|
action: action ? action.split(',') : undefined,
|
||||||
|
status: status ? status.split(',') : undefined,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
startDate: startDate ? new Date(startDate) : undefined,
|
||||||
|
endDate: endDate ? new Date(endDate) : undefined,
|
||||||
|
offset: (page - 1) * limit,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { logs, total } = await this.auditService.getAuditLogs(filters);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs: logs.map((log) => this.mapToDto(log)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get specific audit log by ID
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Get audit log by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Audit log retrieved successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Audit log not found' })
|
||||||
|
async getAuditLogById(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<AuditLogResponseDto> {
|
||||||
|
const log = await this.auditService.getAuditLogs({
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!log.logs.length) {
|
||||||
|
throw new Error('Audit log not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapToDto(log.logs[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit trail for a specific resource
|
||||||
|
*/
|
||||||
|
@Get('resource/:type/:id')
|
||||||
|
@Roles('admin', 'manager', 'user')
|
||||||
|
@ApiOperation({ summary: 'Get audit trail for a specific resource' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Audit trail retrieved successfully' })
|
||||||
|
async getResourceAuditTrail(
|
||||||
|
@Param('type') resourceType: string,
|
||||||
|
@Param('id') resourceId: string,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<AuditLogResponseDto[]> {
|
||||||
|
const logs = await this.auditService.getResourceAuditTrail(resourceType, resourceId);
|
||||||
|
|
||||||
|
// Filter by organization for security
|
||||||
|
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId);
|
||||||
|
|
||||||
|
return filteredLogs.map((log) => this.mapToDto(log));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent activity for current organization
|
||||||
|
*/
|
||||||
|
@Get('organization/activity')
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Get recent organization activity' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Organization activity retrieved successfully' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' })
|
||||||
|
async getOrganizationActivity(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
||||||
|
): Promise<AuditLogResponseDto[]> {
|
||||||
|
limit = limit || 50;
|
||||||
|
const logs = await this.auditService.getOrganizationActivity(user.organizationId, limit);
|
||||||
|
return logs.map((log) => this.mapToDto(log));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user activity history
|
||||||
|
*/
|
||||||
|
@Get('user/:userId/activity')
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Get user activity history' })
|
||||||
|
@ApiResponse({ status: 200, description: 'User activity retrieved successfully' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, description: 'Number of recent logs (default: 50)' })
|
||||||
|
async getUserActivity(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
||||||
|
): Promise<AuditLogResponseDto[]> {
|
||||||
|
limit = limit || 50;
|
||||||
|
const logs = await this.auditService.getUserActivity(userId, limit);
|
||||||
|
|
||||||
|
// Filter by organization for security
|
||||||
|
const filteredLogs = logs.filter((log) => log.organizationId === user.organizationId);
|
||||||
|
|
||||||
|
return filteredLogs.map((log) => this.mapToDto(log));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map domain entity to DTO
|
||||||
|
*/
|
||||||
|
private mapToDto(log: AuditLog): AuditLogResponseDto {
|
||||||
|
return {
|
||||||
|
id: log.id,
|
||||||
|
action: log.action,
|
||||||
|
status: log.status,
|
||||||
|
userId: log.userId,
|
||||||
|
userEmail: log.userEmail,
|
||||||
|
organizationId: log.organizationId,
|
||||||
|
resourceType: log.resourceType,
|
||||||
|
resourceId: log.resourceId,
|
||||||
|
resourceName: log.resourceName,
|
||||||
|
metadata: log.metadata,
|
||||||
|
ipAddress: log.ipAddress,
|
||||||
|
userAgent: log.userAgent,
|
||||||
|
errorMessage: log.errorMessage,
|
||||||
|
timestamp: log.timestamp.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,8 @@ import {
|
|||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -26,12 +28,16 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
|
ApiProduces,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import { Response } from 'express';
|
||||||
import {
|
import {
|
||||||
CreateBookingRequestDto,
|
CreateBookingRequestDto,
|
||||||
BookingResponseDto,
|
BookingResponseDto,
|
||||||
BookingListResponseDto,
|
BookingListResponseDto,
|
||||||
} from '../dto';
|
} from '../dto';
|
||||||
|
import { BookingFilterDto } from '../dto/booking-filter.dto';
|
||||||
|
import { BookingExportDto, ExportFormat } from '../dto/booking-export.dto';
|
||||||
import { BookingMapper } from '../mappers';
|
import { BookingMapper } from '../mappers';
|
||||||
import { BookingService } from '../../domain/services/booking.service';
|
import { BookingService } from '../../domain/services/booking.service';
|
||||||
import { BookingRepository } from '../../domain/ports/out/booking.repository';
|
import { BookingRepository } from '../../domain/ports/out/booking.repository';
|
||||||
@ -39,6 +45,14 @@ import { RateQuoteRepository } from '../../domain/ports/out/rate-quote.repositor
|
|||||||
import { BookingNumber } from '../../domain/value-objects/booking-number.vo';
|
import { BookingNumber } from '../../domain/value-objects/booking-number.vo';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
|
import { ExportService } from '../services/export.service';
|
||||||
|
import { FuzzySearchService } from '../services/fuzzy-search.service';
|
||||||
|
import { AuditService } from '../services/audit.service';
|
||||||
|
import { AuditAction, AuditStatus } from '../../domain/entities/audit-log.entity';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
|
import { NotificationsGateway } from '../gateways/notifications.gateway';
|
||||||
|
import { WebhookService } from '../services/webhook.service';
|
||||||
|
import { WebhookEvent } from '../../domain/entities/webhook.entity';
|
||||||
|
|
||||||
@ApiTags('Bookings')
|
@ApiTags('Bookings')
|
||||||
@Controller('api/v1/bookings')
|
@Controller('api/v1/bookings')
|
||||||
@ -51,6 +65,12 @@ export class BookingsController {
|
|||||||
private readonly bookingService: BookingService,
|
private readonly bookingService: BookingService,
|
||||||
private readonly bookingRepository: BookingRepository,
|
private readonly bookingRepository: BookingRepository,
|
||||||
private readonly rateQuoteRepository: RateQuoteRepository,
|
private readonly rateQuoteRepository: RateQuoteRepository,
|
||||||
|
private readonly exportService: ExportService,
|
||||||
|
private readonly fuzzySearchService: FuzzySearchService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly notificationsGateway: NotificationsGateway,
|
||||||
|
private readonly webhookService: WebhookService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ -111,12 +131,84 @@ export class BookingsController {
|
|||||||
`Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`,
|
`Booking created successfully: ${booking.bookingNumber.value} (${booking.id})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Audit log: Booking created
|
||||||
|
await this.auditService.logSuccess(
|
||||||
|
AuditAction.BOOKING_CREATED,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
user.organizationId,
|
||||||
|
{
|
||||||
|
resourceType: 'booking',
|
||||||
|
resourceId: booking.id,
|
||||||
|
resourceName: booking.bookingNumber.value,
|
||||||
|
metadata: {
|
||||||
|
rateQuoteId: dto.rateQuoteId,
|
||||||
|
status: booking.status.value,
|
||||||
|
carrier: rateQuote.carrierName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send real-time notification
|
||||||
|
try {
|
||||||
|
const notification = await this.notificationService.notifyBookingCreated(
|
||||||
|
user.id,
|
||||||
|
user.organizationId,
|
||||||
|
booking.bookingNumber.value,
|
||||||
|
booking.id,
|
||||||
|
);
|
||||||
|
await this.notificationsGateway.sendNotificationToUser(user.id, notification);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Don't fail the booking creation if notification fails
|
||||||
|
this.logger.error(`Failed to send notification: ${error?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger webhooks
|
||||||
|
try {
|
||||||
|
await this.webhookService.triggerWebhooks(
|
||||||
|
WebhookEvent.BOOKING_CREATED,
|
||||||
|
user.organizationId,
|
||||||
|
{
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingNumber: booking.bookingNumber.value,
|
||||||
|
status: booking.status.value,
|
||||||
|
shipper: booking.shipper,
|
||||||
|
consignee: booking.consignee,
|
||||||
|
carrier: rateQuote.carrierName,
|
||||||
|
origin: rateQuote.origin,
|
||||||
|
destination: rateQuote.destination,
|
||||||
|
etd: rateQuote.etd?.toISOString(),
|
||||||
|
eta: rateQuote.eta?.toISOString(),
|
||||||
|
createdAt: booking.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Don't fail the booking creation if webhook fails
|
||||||
|
this.logger.error(`Failed to trigger webhooks: ${error?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Booking creation failed: ${error?.message || 'Unknown error'}`,
|
`Booking creation failed: ${error?.message || 'Unknown error'}`,
|
||||||
error?.stack,
|
error?.stack,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Audit log: Booking creation failed
|
||||||
|
await this.auditService.logFailure(
|
||||||
|
AuditAction.BOOKING_CREATED,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
user.organizationId,
|
||||||
|
error?.message || 'Unknown error',
|
||||||
|
{
|
||||||
|
resourceType: 'booking',
|
||||||
|
metadata: {
|
||||||
|
rateQuoteId: dto.rateQuoteId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -312,4 +404,289 @@ export class BookingsController {
|
|||||||
totalPages,
|
totalPages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('search/fuzzy')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Fuzzy search bookings',
|
||||||
|
description:
|
||||||
|
'Search bookings using fuzzy matching. Tolerant to typos and partial matches. Searches across booking number, shipper, and consignee names.',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'q',
|
||||||
|
required: true,
|
||||||
|
description: 'Search query (minimum 2 characters)',
|
||||||
|
example: 'WCM-2025',
|
||||||
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'limit',
|
||||||
|
required: false,
|
||||||
|
description: 'Maximum number of results',
|
||||||
|
example: 20,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Search results retrieved successfully',
|
||||||
|
type: [BookingResponseDto],
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - missing or invalid token',
|
||||||
|
})
|
||||||
|
async fuzzySearch(
|
||||||
|
@Query('q') searchTerm: string,
|
||||||
|
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<BookingResponseDto[]> {
|
||||||
|
this.logger.log(`[User: ${user.email}] Fuzzy search: "${searchTerm}"`);
|
||||||
|
|
||||||
|
if (!searchTerm || searchTerm.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform fuzzy search
|
||||||
|
const bookingOrms = await this.fuzzySearchService.search(
|
||||||
|
searchTerm,
|
||||||
|
user.organizationId,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map ORM entities to domain and fetch rate quotes
|
||||||
|
const bookingsWithQuotes = await Promise.all(
|
||||||
|
bookingOrms.map(async (bookingOrm) => {
|
||||||
|
const booking = await this.bookingRepository.findById(bookingOrm.id);
|
||||||
|
const rateQuote = await this.rateQuoteRepository.findById(bookingOrm.rateQuoteId);
|
||||||
|
return { booking: booking!, rateQuote: rateQuote! };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to DTOs
|
||||||
|
const bookingDtos = bookingsWithQuotes.map(({ booking, rateQuote }) =>
|
||||||
|
BookingMapper.toDto(booking, rateQuote),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Fuzzy search returned ${bookingDtos.length} results`);
|
||||||
|
|
||||||
|
return bookingDtos;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('advanced/search')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Advanced booking search with filtering',
|
||||||
|
description:
|
||||||
|
'Search bookings with advanced filtering options including status, date ranges, carrier, ports, shipper/consignee. Supports sorting and pagination.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Filtered bookings retrieved successfully',
|
||||||
|
type: BookingListResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - missing or invalid token',
|
||||||
|
})
|
||||||
|
async advancedSearch(
|
||||||
|
@Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<BookingListResponseDto> {
|
||||||
|
this.logger.log(
|
||||||
|
`[User: ${user.email}] Advanced search with filters: ${JSON.stringify(filter)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch all bookings for organization
|
||||||
|
let bookings = await this.bookingRepository.findByOrganization(user.organizationId);
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
bookings = this.applyFilters(bookings, filter);
|
||||||
|
|
||||||
|
// Sort bookings
|
||||||
|
bookings = this.sortBookings(bookings, filter.sortBy!, filter.sortOrder!);
|
||||||
|
|
||||||
|
// Total count before pagination
|
||||||
|
const total = bookings.length;
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
const startIndex = ((filter.page || 1) - 1) * (filter.pageSize || 20);
|
||||||
|
const endIndex = startIndex + (filter.pageSize || 20);
|
||||||
|
const paginatedBookings = bookings.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
// Fetch rate quotes
|
||||||
|
const bookingsWithQuotes = await Promise.all(
|
||||||
|
paginatedBookings.map(async (booking) => {
|
||||||
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
|
return { booking, rateQuote: rateQuote! };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to DTOs
|
||||||
|
const bookingDtos = BookingMapper.toListItemDtoArray(bookingsWithQuotes);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / (filter.pageSize || 20));
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookings: bookingDtos,
|
||||||
|
total,
|
||||||
|
page: filter.page || 1,
|
||||||
|
pageSize: filter.pageSize || 20,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('export')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Export bookings to CSV/Excel/JSON',
|
||||||
|
description:
|
||||||
|
'Export bookings with optional filtering. Supports CSV, Excel (xlsx), and JSON formats.',
|
||||||
|
})
|
||||||
|
@ApiProduces('text/csv', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/json')
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Export file generated successfully',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - missing or invalid token',
|
||||||
|
})
|
||||||
|
async exportBookings(
|
||||||
|
@Body(new ValidationPipe({ transform: true })) exportDto: BookingExportDto,
|
||||||
|
@Query(new ValidationPipe({ transform: true })) filter: BookingFilterDto,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<StreamableFile> {
|
||||||
|
this.logger.log(
|
||||||
|
`[User: ${user.email}] Exporting bookings to ${exportDto.format}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let bookings: any[];
|
||||||
|
|
||||||
|
// If specific booking IDs provided, use those
|
||||||
|
if (exportDto.bookingIds && exportDto.bookingIds.length > 0) {
|
||||||
|
bookings = await Promise.all(
|
||||||
|
exportDto.bookingIds.map((id) => this.bookingRepository.findById(id)),
|
||||||
|
);
|
||||||
|
bookings = bookings.filter((b) => b !== null && b.organizationId === user.organizationId);
|
||||||
|
} else {
|
||||||
|
// Otherwise, use filter criteria
|
||||||
|
bookings = await this.bookingRepository.findByOrganization(user.organizationId);
|
||||||
|
bookings = this.applyFilters(bookings, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch rate quotes
|
||||||
|
const bookingsWithQuotes = await Promise.all(
|
||||||
|
bookings.map(async (booking) => {
|
||||||
|
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
|
||||||
|
return { booking, rateQuote: rateQuote! };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate export file
|
||||||
|
const exportResult = await this.exportService.exportBookings(
|
||||||
|
bookingsWithQuotes,
|
||||||
|
exportDto.format,
|
||||||
|
exportDto.fields,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
res.set({
|
||||||
|
'Content-Type': exportResult.contentType,
|
||||||
|
'Content-Disposition': `attachment; filename="${exportResult.filename}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit log: Data exported
|
||||||
|
await this.auditService.logSuccess(
|
||||||
|
AuditAction.DATA_EXPORTED,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
user.organizationId,
|
||||||
|
{
|
||||||
|
resourceType: 'booking',
|
||||||
|
metadata: {
|
||||||
|
format: exportDto.format,
|
||||||
|
bookingCount: bookings.length,
|
||||||
|
fields: exportDto.fields?.join(', ') || 'all',
|
||||||
|
filename: exportResult.filename,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return new StreamableFile(exportResult.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply filters to bookings array
|
||||||
|
*/
|
||||||
|
private applyFilters(bookings: any[], filter: BookingFilterDto): any[] {
|
||||||
|
let filtered = bookings;
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if (filter.status && filter.status.length > 0) {
|
||||||
|
filtered = filtered.filter((b) => filter.status!.includes(b.status.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search (booking number partial match)
|
||||||
|
if (filter.search) {
|
||||||
|
const searchLower = filter.search.toLowerCase();
|
||||||
|
filtered = filtered.filter((b) =>
|
||||||
|
b.bookingNumber.value.toLowerCase().includes(searchLower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by shipper
|
||||||
|
if (filter.shipper) {
|
||||||
|
const shipperLower = filter.shipper.toLowerCase();
|
||||||
|
filtered = filtered.filter((b) =>
|
||||||
|
b.shipper.name.toLowerCase().includes(shipperLower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by consignee
|
||||||
|
if (filter.consignee) {
|
||||||
|
const consigneeLower = filter.consignee.toLowerCase();
|
||||||
|
filtered = filtered.filter((b) =>
|
||||||
|
b.consignee.name.toLowerCase().includes(consigneeLower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by creation date range
|
||||||
|
if (filter.createdFrom) {
|
||||||
|
const fromDate = new Date(filter.createdFrom);
|
||||||
|
filtered = filtered.filter((b) => b.createdAt >= fromDate);
|
||||||
|
}
|
||||||
|
if (filter.createdTo) {
|
||||||
|
const toDate = new Date(filter.createdTo);
|
||||||
|
filtered = filtered.filter((b) => b.createdAt <= toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort bookings array
|
||||||
|
*/
|
||||||
|
private sortBookings(bookings: any[], sortBy: string, sortOrder: string): any[] {
|
||||||
|
return [...bookings].sort((a, b) => {
|
||||||
|
let aValue: any;
|
||||||
|
let bValue: any;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'bookingNumber':
|
||||||
|
aValue = a.bookingNumber.value;
|
||||||
|
bValue = b.bookingNumber.value;
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
aValue = a.status.value;
|
||||||
|
bValue = b.status.value;
|
||||||
|
break;
|
||||||
|
case 'createdAt':
|
||||||
|
default:
|
||||||
|
aValue = a.createdAt;
|
||||||
|
bValue = b.createdAt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aValue < bValue) return sortOrder === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return sortOrder === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Notifications Controller
|
||||||
|
*
|
||||||
|
* REST API endpoints for managing notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseIntPipe,
|
||||||
|
DefaultValuePipe,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
|
import { Notification } from '../../domain/entities/notification.entity';
|
||||||
|
|
||||||
|
class NotificationResponseDto {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
priority: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
read: boolean;
|
||||||
|
readAt?: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Notifications')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('api/v1/notifications')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(private readonly notificationService: NotificationService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's notifications
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get user notifications' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Notifications retrieved successfully' })
|
||||||
|
@ApiQuery({ name: 'read', required: false, description: 'Filter by read status' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, description: 'Items per page (default: 20)' })
|
||||||
|
async getNotifications(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Query('read') read?: string,
|
||||||
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page?: number,
|
||||||
|
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit?: number,
|
||||||
|
): Promise<{
|
||||||
|
notifications: NotificationResponseDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}> {
|
||||||
|
page = page || 1;
|
||||||
|
limit = limit || 20;
|
||||||
|
|
||||||
|
const filters: any = {
|
||||||
|
userId: user.id,
|
||||||
|
read: read !== undefined ? read === 'true' : undefined,
|
||||||
|
offset: (page - 1) * limit,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { notifications, total } = await this.notificationService.getNotifications(filters);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: notifications.map((n) => this.mapToDto(n)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize: limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notifications
|
||||||
|
*/
|
||||||
|
@Get('unread')
|
||||||
|
@ApiOperation({ summary: 'Get unread notifications' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Unread notifications retrieved successfully' })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, description: 'Number of notifications (default: 50)' })
|
||||||
|
async getUnreadNotifications(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit?: number,
|
||||||
|
): Promise<NotificationResponseDto[]> {
|
||||||
|
limit = limit || 50;
|
||||||
|
const notifications = await this.notificationService.getUnreadNotifications(user.id, limit);
|
||||||
|
return notifications.map((n) => this.mapToDto(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread count
|
||||||
|
*/
|
||||||
|
@Get('unread/count')
|
||||||
|
@ApiOperation({ summary: 'Get unread notifications count' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Unread count retrieved successfully' })
|
||||||
|
async getUnreadCount(@CurrentUser() user: UserPayload): Promise<{ count: number }> {
|
||||||
|
const count = await this.notificationService.getUnreadCount(user.id);
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification by ID
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get notification by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Notification retrieved successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
|
async getNotificationById(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<NotificationResponseDto> {
|
||||||
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
|
if (!notification || notification.userId !== user.id) {
|
||||||
|
throw new NotFoundException('Notification not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapToDto(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notification as read
|
||||||
|
*/
|
||||||
|
@Patch(':id/read')
|
||||||
|
@ApiOperation({ summary: 'Mark notification as read' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Notification marked as read' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
|
async markAsRead(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
|
if (!notification || notification.userId !== user.id) {
|
||||||
|
throw new NotFoundException('Notification not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.notificationService.markAsRead(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
*/
|
||||||
|
@Post('read-all')
|
||||||
|
@ApiOperation({ summary: 'Mark all notifications as read' })
|
||||||
|
@ApiResponse({ status: 200, description: 'All notifications marked as read' })
|
||||||
|
async markAllAsRead(@CurrentUser() user: UserPayload): Promise<{ success: boolean }> {
|
||||||
|
await this.notificationService.markAllAsRead(user.id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete notification
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete notification' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Notification deleted' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Notification not found' })
|
||||||
|
async deleteNotification(
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
@Param('id') id: string,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const notification = await this.notificationService.getNotificationById(id);
|
||||||
|
|
||||||
|
if (!notification || notification.userId !== user.id) {
|
||||||
|
throw new NotFoundException('Notification not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.notificationService.deleteNotification(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map notification entity to DTO
|
||||||
|
*/
|
||||||
|
private mapToDto(notification: Notification): NotificationResponseDto {
|
||||||
|
return {
|
||||||
|
id: notification.id,
|
||||||
|
type: notification.type,
|
||||||
|
priority: notification.priority,
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
metadata: notification.metadata,
|
||||||
|
read: notification.read,
|
||||||
|
readAt: notification.readAt?.toISOString(),
|
||||||
|
actionUrl: notification.actionUrl,
|
||||||
|
createdAt: notification.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
258
apps/backend/src/application/controllers/webhooks.controller.ts
Normal file
258
apps/backend/src/application/controllers/webhooks.controller.ts
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Webhooks Controller
|
||||||
|
*
|
||||||
|
* REST API endpoints for managing webhooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { WebhookService, CreateWebhookInput, UpdateWebhookInput } from '../services/webhook.service';
|
||||||
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
|
import { Webhook, WebhookEvent } from '../../domain/entities/webhook.entity';
|
||||||
|
|
||||||
|
class CreateWebhookDto {
|
||||||
|
url: string;
|
||||||
|
events: WebhookEvent[];
|
||||||
|
description?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateWebhookDto {
|
||||||
|
url?: string;
|
||||||
|
events?: WebhookEvent[];
|
||||||
|
description?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebhookResponseDto {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
events: WebhookEvent[];
|
||||||
|
status: string;
|
||||||
|
description?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
retryCount: number;
|
||||||
|
lastTriggeredAt?: string;
|
||||||
|
failureCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Webhooks')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('api/v1/webhooks')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class WebhooksController {
|
||||||
|
constructor(private readonly webhookService: WebhookService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new webhook
|
||||||
|
* Only admins and managers can create webhooks
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Create a new webhook' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Webhook created successfully' })
|
||||||
|
async createWebhook(
|
||||||
|
@Body() dto: CreateWebhookDto,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<WebhookResponseDto> {
|
||||||
|
const input: CreateWebhookInput = {
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
url: dto.url,
|
||||||
|
events: dto.events,
|
||||||
|
description: dto.description,
|
||||||
|
headers: dto.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const webhook = await this.webhookService.createWebhook(input);
|
||||||
|
return this.mapToDto(webhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all webhooks for organization
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Get all webhooks for organization' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhooks retrieved successfully' })
|
||||||
|
async getWebhooks(@CurrentUser() user: UserPayload): Promise<WebhookResponseDto[]> {
|
||||||
|
const webhooks = await this.webhookService.getWebhooksByOrganization(
|
||||||
|
user.organizationId,
|
||||||
|
);
|
||||||
|
return webhooks.map((w) => this.mapToDto(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get webhook by ID
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Get webhook by ID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhook retrieved successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
|
async getWebhookById(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<WebhookResponseDto> {
|
||||||
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
throw new NotFoundException('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify webhook belongs to user's organization
|
||||||
|
if (webhook.organizationId !== user.organizationId) {
|
||||||
|
throw new ForbiddenException('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapToDto(webhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update webhook
|
||||||
|
*/
|
||||||
|
@Patch(':id')
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Update webhook' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhook updated successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
|
async updateWebhook(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateWebhookDto,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<WebhookResponseDto> {
|
||||||
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
throw new NotFoundException('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify webhook belongs to user's organization
|
||||||
|
if (webhook.organizationId !== user.organizationId) {
|
||||||
|
throw new ForbiddenException('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedWebhook = await this.webhookService.updateWebhook(id, dto);
|
||||||
|
return this.mapToDto(updatedWebhook);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate webhook
|
||||||
|
*/
|
||||||
|
@Post(':id/activate')
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Activate webhook' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhook activated successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
|
async activateWebhook(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
throw new NotFoundException('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify webhook belongs to user's organization
|
||||||
|
if (webhook.organizationId !== user.organizationId) {
|
||||||
|
throw new ForbiddenException('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.webhookService.activateWebhook(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate webhook
|
||||||
|
*/
|
||||||
|
@Post(':id/deactivate')
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Deactivate webhook' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhook deactivated successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
|
async deactivateWebhook(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
throw new NotFoundException('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify webhook belongs to user's organization
|
||||||
|
if (webhook.organizationId !== user.organizationId) {
|
||||||
|
throw new ForbiddenException('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.webhookService.deactivateWebhook(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete webhook
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
@Roles('admin', 'manager')
|
||||||
|
@ApiOperation({ summary: 'Delete webhook' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Webhook deleted successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Webhook not found' })
|
||||||
|
async deleteWebhook(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() user: UserPayload,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const webhook = await this.webhookService.getWebhookById(id);
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
throw new NotFoundException('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify webhook belongs to user's organization
|
||||||
|
if (webhook.organizationId !== user.organizationId) {
|
||||||
|
throw new ForbiddenException('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.webhookService.deleteWebhook(id);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map webhook entity to DTO (without exposing secret)
|
||||||
|
*/
|
||||||
|
private mapToDto(webhook: Webhook): WebhookResponseDto {
|
||||||
|
return {
|
||||||
|
id: webhook.id,
|
||||||
|
url: webhook.url,
|
||||||
|
events: webhook.events,
|
||||||
|
status: webhook.status,
|
||||||
|
description: webhook.description,
|
||||||
|
headers: webhook.headers,
|
||||||
|
retryCount: webhook.retryCount,
|
||||||
|
lastTriggeredAt: webhook.lastTriggeredAt?.toISOString(),
|
||||||
|
failureCount: webhook.failureCount,
|
||||||
|
createdAt: webhook.createdAt.toISOString(),
|
||||||
|
updatedAt: webhook.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
68
apps/backend/src/application/dto/booking-export.dto.ts
Normal file
68
apps/backend/src/application/dto/booking-export.dto.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Booking Export DTO
|
||||||
|
*
|
||||||
|
* Defines export format options
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsOptional, IsArray, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export enum ExportFormat {
|
||||||
|
CSV = 'csv',
|
||||||
|
EXCEL = 'excel',
|
||||||
|
JSON = 'json',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ExportField {
|
||||||
|
BOOKING_NUMBER = 'bookingNumber',
|
||||||
|
STATUS = 'status',
|
||||||
|
CREATED_AT = 'createdAt',
|
||||||
|
CARRIER = 'carrier',
|
||||||
|
ORIGIN = 'origin',
|
||||||
|
DESTINATION = 'destination',
|
||||||
|
ETD = 'etd',
|
||||||
|
ETA = 'eta',
|
||||||
|
SHIPPER = 'shipper',
|
||||||
|
CONSIGNEE = 'consignee',
|
||||||
|
CONTAINER_TYPE = 'containerType',
|
||||||
|
CONTAINER_COUNT = 'containerCount',
|
||||||
|
TOTAL_TEUS = 'totalTEUs',
|
||||||
|
PRICE = 'price',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingExportDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Export format',
|
||||||
|
enum: ExportFormat,
|
||||||
|
example: ExportFormat.CSV,
|
||||||
|
})
|
||||||
|
@IsEnum(ExportFormat)
|
||||||
|
format: ExportFormat;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Fields to include in export (if omitted, all fields included)',
|
||||||
|
enum: ExportField,
|
||||||
|
isArray: true,
|
||||||
|
example: [
|
||||||
|
ExportField.BOOKING_NUMBER,
|
||||||
|
ExportField.STATUS,
|
||||||
|
ExportField.CARRIER,
|
||||||
|
ExportField.ORIGIN,
|
||||||
|
ExportField.DESTINATION,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsEnum(ExportField, { each: true })
|
||||||
|
fields?: ExportField[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Booking IDs to export (if omitted, exports filtered bookings)',
|
||||||
|
isArray: true,
|
||||||
|
example: ['550e8400-e29b-41d4-a716-446655440000'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
bookingIds?: string[];
|
||||||
|
}
|
||||||
175
apps/backend/src/application/dto/booking-filter.dto.ts
Normal file
175
apps/backend/src/application/dto/booking-filter.dto.ts
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Advanced Booking Filter DTO
|
||||||
|
*
|
||||||
|
* Supports comprehensive filtering for booking searches
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export enum BookingStatusFilter {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
PENDING_CONFIRMATION = 'pending_confirmation',
|
||||||
|
CONFIRMED = 'confirmed',
|
||||||
|
IN_TRANSIT = 'in_transit',
|
||||||
|
DELIVERED = 'delivered',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BookingSortField {
|
||||||
|
CREATED_AT = 'createdAt',
|
||||||
|
BOOKING_NUMBER = 'bookingNumber',
|
||||||
|
STATUS = 'status',
|
||||||
|
ETD = 'etd',
|
||||||
|
ETA = 'eta',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SortOrder {
|
||||||
|
ASC = 'asc',
|
||||||
|
DESC = 'desc',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingFilterDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Page number (1-based)',
|
||||||
|
example: 1,
|
||||||
|
minimum: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Number of items per page',
|
||||||
|
example: 20,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 100,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
@Type(() => Number)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by booking status (multiple)',
|
||||||
|
enum: BookingStatusFilter,
|
||||||
|
isArray: true,
|
||||||
|
example: ['confirmed', 'in_transit'],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsEnum(BookingStatusFilter, { each: true })
|
||||||
|
status?: BookingStatusFilter[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Search by booking number (partial match)',
|
||||||
|
example: 'WCM-2025',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by carrier name or code',
|
||||||
|
example: 'Maersk',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
carrier?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by origin port code',
|
||||||
|
example: 'NLRTM',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
originPort?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by destination port code',
|
||||||
|
example: 'CNSHA',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
destinationPort?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by shipper name (partial match)',
|
||||||
|
example: 'Acme Corp',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
shipper?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by consignee name (partial match)',
|
||||||
|
example: 'XYZ Ltd',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
consignee?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by creation date from (ISO 8601)',
|
||||||
|
example: '2025-01-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by creation date to (ISO 8601)',
|
||||||
|
example: '2025-12-31T23:59:59.999Z',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
createdTo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by ETD from (ISO 8601)',
|
||||||
|
example: '2025-06-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
etdFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Filter by ETD to (ISO 8601)',
|
||||||
|
example: '2025-06-30T23:59:59.999Z',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
etdTo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sort field',
|
||||||
|
enum: BookingSortField,
|
||||||
|
example: BookingSortField.CREATED_AT,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(BookingSortField)
|
||||||
|
sortBy?: BookingSortField = BookingSortField.CREATED_AT;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sort order',
|
||||||
|
enum: SortOrder,
|
||||||
|
example: SortOrder.DESC,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SortOrder)
|
||||||
|
sortOrder?: SortOrder = SortOrder.DESC;
|
||||||
|
}
|
||||||
@ -5,3 +5,5 @@ export * from './rate-search-response.dto';
|
|||||||
// Booking DTOs
|
// Booking DTOs
|
||||||
export * from './create-booking-request.dto';
|
export * from './create-booking-request.dto';
|
||||||
export * from './booking-response.dto';
|
export * from './booking-response.dto';
|
||||||
|
export * from './booking-filter.dto';
|
||||||
|
export * from './booking-export.dto';
|
||||||
|
|||||||
243
apps/backend/src/application/gateways/notifications.gateway.ts
Normal file
243
apps/backend/src/application/gateways/notifications.gateway.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Notifications WebSocket Gateway
|
||||||
|
*
|
||||||
|
* Handles real-time notification delivery via WebSocket
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { Logger, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
|
import { Notification } from '../../domain/entities/notification.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket authentication guard
|
||||||
|
*/
|
||||||
|
@UseGuards()
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: {
|
||||||
|
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
namespace: '/notifications',
|
||||||
|
})
|
||||||
|
export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(NotificationsGateway.name);
|
||||||
|
private userSockets: Map<string, Set<string>> = new Map(); // userId -> Set of socket IDs
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle client connection
|
||||||
|
*/
|
||||||
|
async handleConnection(client: Socket) {
|
||||||
|
try {
|
||||||
|
// Extract JWT token from handshake
|
||||||
|
const token = this.extractToken(client);
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn(`Client ${client.id} connection rejected: No token provided`);
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT token
|
||||||
|
const payload = await this.jwtService.verifyAsync(token);
|
||||||
|
const userId = payload.sub;
|
||||||
|
|
||||||
|
// Store socket connection for user
|
||||||
|
if (!this.userSockets.has(userId)) {
|
||||||
|
this.userSockets.set(userId, new Set());
|
||||||
|
}
|
||||||
|
this.userSockets.get(userId)!.add(client.id);
|
||||||
|
|
||||||
|
// Store user ID in socket data for later use
|
||||||
|
client.data.userId = userId;
|
||||||
|
client.data.organizationId = payload.organizationId;
|
||||||
|
|
||||||
|
// Join user-specific room
|
||||||
|
client.join(`user:${userId}`);
|
||||||
|
|
||||||
|
this.logger.log(`Client ${client.id} connected for user ${userId}`);
|
||||||
|
|
||||||
|
// Send unread count on connection
|
||||||
|
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||||||
|
client.emit('unread_count', { count: unreadCount });
|
||||||
|
|
||||||
|
// Send recent notifications on connection
|
||||||
|
const recentNotifications = await this.notificationService.getRecentNotifications(userId, 10);
|
||||||
|
client.emit('recent_notifications', {
|
||||||
|
notifications: recentNotifications.map((n) => this.mapNotificationToDto(n)),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error during client connection: ${error?.message || 'Unknown error'}`,
|
||||||
|
error?.stack,
|
||||||
|
);
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle client disconnection
|
||||||
|
*/
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
if (userId && this.userSockets.has(userId)) {
|
||||||
|
this.userSockets.get(userId)!.delete(client.id);
|
||||||
|
if (this.userSockets.get(userId)!.size === 0) {
|
||||||
|
this.userSockets.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`Client ${client.id} disconnected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mark notification as read
|
||||||
|
*/
|
||||||
|
@SubscribeMessage('mark_as_read')
|
||||||
|
async handleMarkAsRead(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: { notificationId: string },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
await this.notificationService.markAsRead(data.notificationId);
|
||||||
|
|
||||||
|
// Send updated unread count
|
||||||
|
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||||||
|
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Error marking notification as read: ${error?.message}`);
|
||||||
|
return { success: false, error: error?.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mark all notifications as read
|
||||||
|
*/
|
||||||
|
@SubscribeMessage('mark_all_as_read')
|
||||||
|
async handleMarkAllAsRead(@ConnectedSocket() client: Socket) {
|
||||||
|
try {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
await this.notificationService.markAllAsRead(userId);
|
||||||
|
|
||||||
|
// Send updated unread count (should be 0)
|
||||||
|
this.emitToUser(userId, 'unread_count', { count: 0 });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Error marking all notifications as read: ${error?.message}`);
|
||||||
|
return { success: false, error: error?.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle get unread count
|
||||||
|
*/
|
||||||
|
@SubscribeMessage('get_unread_count')
|
||||||
|
async handleGetUnreadCount(@ConnectedSocket() client: Socket) {
|
||||||
|
try {
|
||||||
|
const userId = client.data.userId;
|
||||||
|
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||||||
|
return { count: unreadCount };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Error getting unread count: ${error?.message}`);
|
||||||
|
return { count: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification to a specific user
|
||||||
|
*/
|
||||||
|
async sendNotificationToUser(userId: string, notification: Notification) {
|
||||||
|
const notificationDto = this.mapNotificationToDto(notification);
|
||||||
|
|
||||||
|
// Emit to all connected sockets for this user
|
||||||
|
this.emitToUser(userId, 'new_notification', { notification: notificationDto });
|
||||||
|
|
||||||
|
// Update unread count
|
||||||
|
const unreadCount = await this.notificationService.getUnreadCount(userId);
|
||||||
|
this.emitToUser(userId, 'unread_count', { count: unreadCount });
|
||||||
|
|
||||||
|
this.logger.log(`Notification sent to user ${userId}: ${notification.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast notification to organization
|
||||||
|
*/
|
||||||
|
async broadcastToOrganization(organizationId: string, notification: Notification) {
|
||||||
|
const notificationDto = this.mapNotificationToDto(notification);
|
||||||
|
this.server.to(`org:${organizationId}`).emit('new_notification', {
|
||||||
|
notification: notificationDto,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Notification broadcasted to organization ${organizationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Emit event to all sockets of a user
|
||||||
|
*/
|
||||||
|
private emitToUser(userId: string, event: string, data: any) {
|
||||||
|
this.server.to(`user:${userId}`).emit(event, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Extract JWT token from socket handshake
|
||||||
|
*/
|
||||||
|
private extractToken(client: Socket): string | null {
|
||||||
|
// Check Authorization header
|
||||||
|
const authHeader = client.handshake.headers.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
return authHeader.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check query parameter
|
||||||
|
const token = client.handshake.query.token;
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check auth object (socket.io-client way)
|
||||||
|
const auth = client.handshake.auth;
|
||||||
|
if (auth && typeof auth.token === 'string') {
|
||||||
|
return auth.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Map notification entity to DTO
|
||||||
|
*/
|
||||||
|
private mapNotificationToDto(notification: Notification) {
|
||||||
|
return {
|
||||||
|
id: notification.id,
|
||||||
|
type: notification.type,
|
||||||
|
priority: notification.priority,
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
metadata: notification.metadata,
|
||||||
|
read: notification.read,
|
||||||
|
readAt: notification.readAt?.toISOString(),
|
||||||
|
actionUrl: notification.actionUrl,
|
||||||
|
createdAt: notification.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Notifications Module
|
||||||
|
*
|
||||||
|
* Provides notification functionality with WebSocket support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { NotificationsController } from '../controllers/notifications.controller';
|
||||||
|
import { NotificationsGateway } from '../gateways/notifications.gateway';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
|
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
||||||
|
import { TypeOrmNotificationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-notification.repository';
|
||||||
|
import { NOTIFICATION_REPOSITORY } from '../../domain/ports/out/notification.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([NotificationOrmEntity]),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION', '15m'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [
|
||||||
|
NotificationsGateway,
|
||||||
|
NotificationService,
|
||||||
|
{
|
||||||
|
provide: NOTIFICATION_REPOSITORY,
|
||||||
|
useClass: TypeOrmNotificationRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [NotificationService, NotificationsGateway],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
165
apps/backend/src/application/services/audit.service.ts
Normal file
165
apps/backend/src/application/services/audit.service.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Audit Service
|
||||||
|
*
|
||||||
|
* Provides centralized audit logging functionality
|
||||||
|
* Tracks all important actions for security and compliance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
AuditLog,
|
||||||
|
AuditAction,
|
||||||
|
AuditStatus,
|
||||||
|
} from '../../domain/entities/audit-log.entity';
|
||||||
|
import {
|
||||||
|
AuditLogRepository,
|
||||||
|
AUDIT_LOG_REPOSITORY,
|
||||||
|
AuditLogFilters,
|
||||||
|
} from '../../domain/ports/out/audit-log.repository';
|
||||||
|
|
||||||
|
export interface LogAuditInput {
|
||||||
|
action: AuditAction;
|
||||||
|
status: AuditStatus;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
organizationId: string;
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
resourceName?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditService {
|
||||||
|
private readonly logger = new Logger(AuditService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(AUDIT_LOG_REPOSITORY)
|
||||||
|
private readonly auditLogRepository: AuditLogRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an audit event
|
||||||
|
*/
|
||||||
|
async log(input: LogAuditInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
const auditLog = AuditLog.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.auditLogRepository.save(auditLog);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Audit log created: ${input.action} by ${input.userEmail} (${input.status})`,
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Never throw on audit logging failure - log the error and continue
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create audit log: ${error?.message || 'Unknown error'}`,
|
||||||
|
error?.stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log successful action
|
||||||
|
*/
|
||||||
|
async logSuccess(
|
||||||
|
action: AuditAction,
|
||||||
|
userId: string,
|
||||||
|
userEmail: string,
|
||||||
|
organizationId: string,
|
||||||
|
options?: {
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
resourceName?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.log({
|
||||||
|
action,
|
||||||
|
status: AuditStatus.SUCCESS,
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
organizationId,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log failed action
|
||||||
|
*/
|
||||||
|
async logFailure(
|
||||||
|
action: AuditAction,
|
||||||
|
userId: string,
|
||||||
|
userEmail: string,
|
||||||
|
organizationId: string,
|
||||||
|
errorMessage: string,
|
||||||
|
options?: {
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
await this.log({
|
||||||
|
action,
|
||||||
|
status: AuditStatus.FAILURE,
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
organizationId,
|
||||||
|
errorMessage,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs with filters
|
||||||
|
*/
|
||||||
|
async getAuditLogs(filters: AuditLogFilters): Promise<{
|
||||||
|
logs: AuditLog[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
this.auditLogRepository.findByFilters(filters),
|
||||||
|
this.auditLogRepository.count(filters),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { logs, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit trail for a specific resource
|
||||||
|
*/
|
||||||
|
async getResourceAuditTrail(
|
||||||
|
resourceType: string,
|
||||||
|
resourceId: string,
|
||||||
|
): Promise<AuditLog[]> {
|
||||||
|
return this.auditLogRepository.findByResource(resourceType, resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent activity for an organization
|
||||||
|
*/
|
||||||
|
async getOrganizationActivity(
|
||||||
|
organizationId: string,
|
||||||
|
limit: number = 50,
|
||||||
|
): Promise<AuditLog[]> {
|
||||||
|
return this.auditLogRepository.findRecentByOrganization(organizationId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user activity history
|
||||||
|
*/
|
||||||
|
async getUserActivity(userId: string, limit: number = 50): Promise<AuditLog[]> {
|
||||||
|
return this.auditLogRepository.findByUser(userId, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
265
apps/backend/src/application/services/export.service.ts
Normal file
265
apps/backend/src/application/services/export.service.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Export Service
|
||||||
|
*
|
||||||
|
* Handles booking data export to various formats (CSV, Excel, JSON)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Booking } from '../../domain/entities/booking.entity';
|
||||||
|
import { RateQuote } from '../../domain/entities/rate-quote.entity';
|
||||||
|
import { ExportFormat, ExportField } from '../dto/booking-export.dto';
|
||||||
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
interface BookingExportData {
|
||||||
|
booking: Booking;
|
||||||
|
rateQuote: RateQuote;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExportService {
|
||||||
|
private readonly logger = new Logger(ExportService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export bookings to specified format
|
||||||
|
*/
|
||||||
|
async exportBookings(
|
||||||
|
data: BookingExportData[],
|
||||||
|
format: ExportFormat,
|
||||||
|
fields?: ExportField[],
|
||||||
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
|
this.logger.log(
|
||||||
|
`Exporting ${data.length} bookings to ${format} format with ${fields?.length || 'all'} fields`,
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case ExportFormat.CSV:
|
||||||
|
return this.exportToCSV(data, fields);
|
||||||
|
case ExportFormat.EXCEL:
|
||||||
|
return this.exportToExcel(data, fields);
|
||||||
|
case ExportFormat.JSON:
|
||||||
|
return this.exportToJSON(data, fields);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported export format: ${format}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to CSV format
|
||||||
|
*/
|
||||||
|
private async exportToCSV(
|
||||||
|
data: BookingExportData[],
|
||||||
|
fields?: ExportField[],
|
||||||
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
|
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
|
// Build CSV header
|
||||||
|
const header = selectedFields.map((field) => this.getFieldLabel(field)).join(',');
|
||||||
|
|
||||||
|
// Build CSV rows
|
||||||
|
const csvRows = rows.map((row) =>
|
||||||
|
selectedFields.map((field) => this.escapeCSVValue(row[field] || '')).join(','),
|
||||||
|
);
|
||||||
|
|
||||||
|
const csv = [header, ...csvRows].join('\n');
|
||||||
|
const buffer = Buffer.from(csv, 'utf-8');
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = `bookings_export_${timestamp}.csv`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
contentType: 'text/csv',
|
||||||
|
filename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to Excel format
|
||||||
|
*/
|
||||||
|
private async exportToExcel(
|
||||||
|
data: BookingExportData[],
|
||||||
|
fields?: ExportField[],
|
||||||
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
|
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
workbook.creator = 'Xpeditis';
|
||||||
|
workbook.created = new Date();
|
||||||
|
|
||||||
|
const worksheet = workbook.addWorksheet('Bookings');
|
||||||
|
|
||||||
|
// Add header row with styling
|
||||||
|
const headerRow = worksheet.addRow(
|
||||||
|
selectedFields.map((field) => this.getFieldLabel(field)),
|
||||||
|
);
|
||||||
|
headerRow.font = { bold: true };
|
||||||
|
headerRow.fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFE0E0E0' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const values = selectedFields.map((field) => row[field] || '');
|
||||||
|
worksheet.addRow(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-fit columns
|
||||||
|
worksheet.columns.forEach((column) => {
|
||||||
|
let maxLength = 10;
|
||||||
|
column.eachCell?.({ includeEmpty: false }, (cell) => {
|
||||||
|
const columnLength = cell.value ? String(cell.value).length : 10;
|
||||||
|
if (columnLength > maxLength) {
|
||||||
|
maxLength = columnLength;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
column.width = Math.min(maxLength + 2, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = `bookings_export_${timestamp}.xlsx`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(buffer),
|
||||||
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
filename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to JSON format
|
||||||
|
*/
|
||||||
|
private async exportToJSON(
|
||||||
|
data: BookingExportData[],
|
||||||
|
fields?: ExportField[],
|
||||||
|
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
||||||
|
const selectedFields = fields || Object.values(ExportField);
|
||||||
|
const rows = data.map((item) => this.extractFields(item, selectedFields));
|
||||||
|
|
||||||
|
const json = JSON.stringify(
|
||||||
|
{
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
totalBookings: rows.length,
|
||||||
|
bookings: rows,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(json, 'utf-8');
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = `bookings_export_${timestamp}.json`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
contentType: 'application/json',
|
||||||
|
filename,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract specified fields from booking data
|
||||||
|
*/
|
||||||
|
private extractFields(
|
||||||
|
data: BookingExportData,
|
||||||
|
fields: ExportField[],
|
||||||
|
): Record<string, any> {
|
||||||
|
const { booking, rateQuote } = data;
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
switch (field) {
|
||||||
|
case ExportField.BOOKING_NUMBER:
|
||||||
|
result[field] = booking.bookingNumber.value;
|
||||||
|
break;
|
||||||
|
case ExportField.STATUS:
|
||||||
|
result[field] = booking.status.value;
|
||||||
|
break;
|
||||||
|
case ExportField.CREATED_AT:
|
||||||
|
result[field] = booking.createdAt.toISOString();
|
||||||
|
break;
|
||||||
|
case ExportField.CARRIER:
|
||||||
|
result[field] = rateQuote.carrierName;
|
||||||
|
break;
|
||||||
|
case ExportField.ORIGIN:
|
||||||
|
result[field] = `${rateQuote.origin.name} (${rateQuote.origin.code})`;
|
||||||
|
break;
|
||||||
|
case ExportField.DESTINATION:
|
||||||
|
result[field] = `${rateQuote.destination.name} (${rateQuote.destination.code})`;
|
||||||
|
break;
|
||||||
|
case ExportField.ETD:
|
||||||
|
result[field] = rateQuote.etd.toISOString();
|
||||||
|
break;
|
||||||
|
case ExportField.ETA:
|
||||||
|
result[field] = rateQuote.eta.toISOString();
|
||||||
|
break;
|
||||||
|
case ExportField.SHIPPER:
|
||||||
|
result[field] = booking.shipper.name;
|
||||||
|
break;
|
||||||
|
case ExportField.CONSIGNEE:
|
||||||
|
result[field] = booking.consignee.name;
|
||||||
|
break;
|
||||||
|
case ExportField.CONTAINER_TYPE:
|
||||||
|
result[field] = booking.containers.map((c) => c.type).join(', ');
|
||||||
|
break;
|
||||||
|
case ExportField.CONTAINER_COUNT:
|
||||||
|
result[field] = booking.containers.length;
|
||||||
|
break;
|
||||||
|
case ExportField.TOTAL_TEUS:
|
||||||
|
result[field] = booking.containers.reduce((total, c) => {
|
||||||
|
return total + (c.type.startsWith('20') ? 1 : 2);
|
||||||
|
}, 0);
|
||||||
|
break;
|
||||||
|
case ExportField.PRICE:
|
||||||
|
result[field] = `${rateQuote.pricing.currency} ${rateQuote.pricing.totalAmount.toFixed(2)}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable field label
|
||||||
|
*/
|
||||||
|
private getFieldLabel(field: ExportField): string {
|
||||||
|
const labels: Record<ExportField, string> = {
|
||||||
|
[ExportField.BOOKING_NUMBER]: 'Booking Number',
|
||||||
|
[ExportField.STATUS]: 'Status',
|
||||||
|
[ExportField.CREATED_AT]: 'Created At',
|
||||||
|
[ExportField.CARRIER]: 'Carrier',
|
||||||
|
[ExportField.ORIGIN]: 'Origin',
|
||||||
|
[ExportField.DESTINATION]: 'Destination',
|
||||||
|
[ExportField.ETD]: 'ETD',
|
||||||
|
[ExportField.ETA]: 'ETA',
|
||||||
|
[ExportField.SHIPPER]: 'Shipper',
|
||||||
|
[ExportField.CONSIGNEE]: 'Consignee',
|
||||||
|
[ExportField.CONTAINER_TYPE]: 'Container Type',
|
||||||
|
[ExportField.CONTAINER_COUNT]: 'Container Count',
|
||||||
|
[ExportField.TOTAL_TEUS]: 'Total TEUs',
|
||||||
|
[ExportField.PRICE]: 'Price',
|
||||||
|
};
|
||||||
|
return labels[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape CSV value (handle commas, quotes, newlines)
|
||||||
|
*/
|
||||||
|
private escapeCSVValue(value: string): string {
|
||||||
|
const stringValue = String(value);
|
||||||
|
if (
|
||||||
|
stringValue.includes(',') ||
|
||||||
|
stringValue.includes('"') ||
|
||||||
|
stringValue.includes('\n')
|
||||||
|
) {
|
||||||
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return stringValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
apps/backend/src/application/services/fuzzy-search.service.ts
Normal file
143
apps/backend/src/application/services/fuzzy-search.service.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Fuzzy Search Service
|
||||||
|
*
|
||||||
|
* Provides fuzzy search capabilities for bookings using PostgreSQL full-text search
|
||||||
|
* and Levenshtein distance for typo tolerance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FuzzySearchService {
|
||||||
|
private readonly logger = new Logger(FuzzySearchService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(BookingOrmEntity)
|
||||||
|
private readonly bookingOrmRepository: Repository<BookingOrmEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuzzy search for bookings by booking number, shipper, or consignee
|
||||||
|
* Uses PostgreSQL full-text search with trigram similarity
|
||||||
|
*/
|
||||||
|
async fuzzySearchBookings(
|
||||||
|
searchTerm: string,
|
||||||
|
organizationId: string,
|
||||||
|
limit: number = 20,
|
||||||
|
): Promise<BookingOrmEntity[]> {
|
||||||
|
if (!searchTerm || searchTerm.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Fuzzy search for "${searchTerm}" in organization ${organizationId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use PostgreSQL full-text search with similarity
|
||||||
|
// This requires pg_trgm extension to be enabled
|
||||||
|
const results = await this.bookingOrmRepository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.containers', 'containers')
|
||||||
|
.where('booking.organization_id = :organizationId', { organizationId })
|
||||||
|
.andWhere(
|
||||||
|
`(
|
||||||
|
similarity(booking.booking_number, :searchTerm) > 0.3
|
||||||
|
OR booking.booking_number ILIKE :likeTerm
|
||||||
|
OR similarity(booking.shipper_name, :searchTerm) > 0.3
|
||||||
|
OR booking.shipper_name ILIKE :likeTerm
|
||||||
|
OR similarity(booking.consignee_name, :searchTerm) > 0.3
|
||||||
|
OR booking.consignee_name ILIKE :likeTerm
|
||||||
|
)`,
|
||||||
|
{
|
||||||
|
searchTerm,
|
||||||
|
likeTerm: `%${searchTerm}%`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
`GREATEST(
|
||||||
|
similarity(booking.booking_number, :searchTerm),
|
||||||
|
similarity(booking.shipper_name, :searchTerm),
|
||||||
|
similarity(booking.consignee_name, :searchTerm)
|
||||||
|
)`,
|
||||||
|
'DESC',
|
||||||
|
)
|
||||||
|
.setParameter('searchTerm', searchTerm)
|
||||||
|
.limit(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
this.logger.log(`Found ${results.length} results for fuzzy search`);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for bookings using PostgreSQL full-text search with ts_vector
|
||||||
|
* This provides better performance for large datasets
|
||||||
|
*/
|
||||||
|
async fullTextSearch(
|
||||||
|
searchTerm: string,
|
||||||
|
organizationId: string,
|
||||||
|
limit: number = 20,
|
||||||
|
): Promise<BookingOrmEntity[]> {
|
||||||
|
if (!searchTerm || searchTerm.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Full-text search for "${searchTerm}" in organization ${organizationId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert search term to tsquery format
|
||||||
|
const tsquery = searchTerm
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((term) => term.length > 0)
|
||||||
|
.map((term) => `${term}:*`)
|
||||||
|
.join(' & ');
|
||||||
|
|
||||||
|
const results = await this.bookingOrmRepository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.containers', 'containers')
|
||||||
|
.where('booking.organization_id = :organizationId', { organizationId })
|
||||||
|
.andWhere(
|
||||||
|
`(
|
||||||
|
to_tsvector('english', booking.booking_number) @@ to_tsquery('english', :tsquery)
|
||||||
|
OR to_tsvector('english', booking.shipper_name) @@ to_tsquery('english', :tsquery)
|
||||||
|
OR to_tsvector('english', booking.consignee_name) @@ to_tsquery('english', :tsquery)
|
||||||
|
OR booking.booking_number ILIKE :likeTerm
|
||||||
|
)`,
|
||||||
|
{
|
||||||
|
tsquery,
|
||||||
|
likeTerm: `%${searchTerm}%`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.orderBy('booking.created_at', 'DESC')
|
||||||
|
.limit(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
this.logger.log(`Found ${results.length} results for full-text search`);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined search that tries fuzzy search first, falls back to full-text if no results
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
searchTerm: string,
|
||||||
|
organizationId: string,
|
||||||
|
limit: number = 20,
|
||||||
|
): Promise<BookingOrmEntity[]> {
|
||||||
|
// Try fuzzy search first (more tolerant to typos)
|
||||||
|
let results = await this.fuzzySearchBookings(searchTerm, organizationId, limit);
|
||||||
|
|
||||||
|
// If no results, try full-text search
|
||||||
|
if (results.length === 0) {
|
||||||
|
results = await this.fullTextSearch(searchTerm, organizationId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
218
apps/backend/src/application/services/notification.service.ts
Normal file
218
apps/backend/src/application/services/notification.service.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Notification Service
|
||||||
|
*
|
||||||
|
* Handles creating and sending notifications to users
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
Notification,
|
||||||
|
NotificationType,
|
||||||
|
NotificationPriority,
|
||||||
|
} from '../../domain/entities/notification.entity';
|
||||||
|
import {
|
||||||
|
NotificationRepository,
|
||||||
|
NOTIFICATION_REPOSITORY,
|
||||||
|
NotificationFilters,
|
||||||
|
} from '../../domain/ports/out/notification.repository';
|
||||||
|
|
||||||
|
export interface CreateNotificationInput {
|
||||||
|
userId: string;
|
||||||
|
organizationId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
priority: NotificationPriority;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
actionUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationService {
|
||||||
|
private readonly logger = new Logger(NotificationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(NOTIFICATION_REPOSITORY)
|
||||||
|
private readonly notificationRepository: NotificationRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and send a notification
|
||||||
|
*/
|
||||||
|
async createNotification(input: CreateNotificationInput): Promise<Notification> {
|
||||||
|
try {
|
||||||
|
const notification = Notification.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
...input,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.notificationRepository.save(notification);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Notification created: ${input.type} for user ${input.userId} - ${input.title}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to create notification: ${error?.message || 'Unknown error'}`,
|
||||||
|
error?.stack,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notifications with filters
|
||||||
|
*/
|
||||||
|
async getNotifications(filters: NotificationFilters): Promise<{
|
||||||
|
notifications: Notification[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const [notifications, total] = await Promise.all([
|
||||||
|
this.notificationRepository.findByFilters(filters),
|
||||||
|
this.notificationRepository.count(filters),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { notifications, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification by ID
|
||||||
|
*/
|
||||||
|
async getNotificationById(id: string): Promise<Notification | null> {
|
||||||
|
return this.notificationRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notifications for a user
|
||||||
|
*/
|
||||||
|
async getUnreadNotifications(userId: string, limit: number = 50): Promise<Notification[]> {
|
||||||
|
return this.notificationRepository.findUnreadByUser(userId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread count for a user
|
||||||
|
*/
|
||||||
|
async getUnreadCount(userId: string): Promise<number> {
|
||||||
|
return this.notificationRepository.countUnreadByUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent notifications for a user
|
||||||
|
*/
|
||||||
|
async getRecentNotifications(userId: string, limit: number = 50): Promise<Notification[]> {
|
||||||
|
return this.notificationRepository.findRecentByUser(userId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notification as read
|
||||||
|
*/
|
||||||
|
async markAsRead(id: string): Promise<void> {
|
||||||
|
await this.notificationRepository.markAsRead(id);
|
||||||
|
this.logger.log(`Notification marked as read: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read for a user
|
||||||
|
*/
|
||||||
|
async markAllAsRead(userId: string): Promise<void> {
|
||||||
|
await this.notificationRepository.markAllAsReadForUser(userId);
|
||||||
|
this.logger.log(`All notifications marked as read for user: ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete notification
|
||||||
|
*/
|
||||||
|
async deleteNotification(id: string): Promise<void> {
|
||||||
|
await this.notificationRepository.delete(id);
|
||||||
|
this.logger.log(`Notification deleted: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup old read notifications
|
||||||
|
*/
|
||||||
|
async cleanupOldNotifications(olderThanDays: number = 30): Promise<number> {
|
||||||
|
const deleted = await this.notificationRepository.deleteOldReadNotifications(olderThanDays);
|
||||||
|
this.logger.log(`Cleaned up ${deleted} old read notifications`);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper methods for creating specific notification types
|
||||||
|
*/
|
||||||
|
|
||||||
|
async notifyBookingCreated(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
bookingNumber: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Notification> {
|
||||||
|
return this.createNotification({
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
type: NotificationType.BOOKING_CREATED,
|
||||||
|
priority: NotificationPriority.MEDIUM,
|
||||||
|
title: 'Booking Created',
|
||||||
|
message: `Your booking ${bookingNumber} has been created successfully.`,
|
||||||
|
metadata: { bookingId, bookingNumber },
|
||||||
|
actionUrl: `/bookings/${bookingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyBookingUpdated(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
bookingNumber: string,
|
||||||
|
bookingId: string,
|
||||||
|
status: string,
|
||||||
|
): Promise<Notification> {
|
||||||
|
return this.createNotification({
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
type: NotificationType.BOOKING_UPDATED,
|
||||||
|
priority: NotificationPriority.MEDIUM,
|
||||||
|
title: 'Booking Updated',
|
||||||
|
message: `Booking ${bookingNumber} status changed to ${status}.`,
|
||||||
|
metadata: { bookingId, bookingNumber, status },
|
||||||
|
actionUrl: `/bookings/${bookingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyBookingConfirmed(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
bookingNumber: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Notification> {
|
||||||
|
return this.createNotification({
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
type: NotificationType.BOOKING_CONFIRMED,
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
title: 'Booking Confirmed',
|
||||||
|
message: `Your booking ${bookingNumber} has been confirmed by the carrier.`,
|
||||||
|
metadata: { bookingId, bookingNumber },
|
||||||
|
actionUrl: `/bookings/${bookingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyDocumentUploaded(
|
||||||
|
userId: string,
|
||||||
|
organizationId: string,
|
||||||
|
documentName: string,
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<Notification> {
|
||||||
|
return this.createNotification({
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
type: NotificationType.DOCUMENT_UPLOADED,
|
||||||
|
priority: NotificationPriority.LOW,
|
||||||
|
title: 'Document Uploaded',
|
||||||
|
message: `Document "${documentName}" has been uploaded for your booking.`,
|
||||||
|
metadata: { documentName, bookingId },
|
||||||
|
actionUrl: `/bookings/${bookingId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
294
apps/backend/src/application/services/webhook.service.ts
Normal file
294
apps/backend/src/application/services/webhook.service.ts
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Webhook Service
|
||||||
|
*
|
||||||
|
* Handles webhook management and triggering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import {
|
||||||
|
Webhook,
|
||||||
|
WebhookEvent,
|
||||||
|
WebhookStatus,
|
||||||
|
} from '../../domain/entities/webhook.entity';
|
||||||
|
import {
|
||||||
|
WebhookRepository,
|
||||||
|
WEBHOOK_REPOSITORY,
|
||||||
|
WebhookFilters,
|
||||||
|
} from '../../domain/ports/out/webhook.repository';
|
||||||
|
|
||||||
|
export interface CreateWebhookInput {
|
||||||
|
organizationId: string;
|
||||||
|
url: string;
|
||||||
|
events: WebhookEvent[];
|
||||||
|
description?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWebhookInput {
|
||||||
|
url?: string;
|
||||||
|
events?: WebhookEvent[];
|
||||||
|
description?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookPayload {
|
||||||
|
event: WebhookEvent;
|
||||||
|
timestamp: string;
|
||||||
|
data: any;
|
||||||
|
organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebhookService {
|
||||||
|
private readonly logger = new Logger(WebhookService.name);
|
||||||
|
private readonly MAX_RETRIES = 3;
|
||||||
|
private readonly RETRY_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(WEBHOOK_REPOSITORY)
|
||||||
|
private readonly webhookRepository: WebhookRepository,
|
||||||
|
private readonly httpService: HttpService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new webhook
|
||||||
|
*/
|
||||||
|
async createWebhook(input: CreateWebhookInput): Promise<Webhook> {
|
||||||
|
const secret = this.generateSecret();
|
||||||
|
|
||||||
|
const webhook = Webhook.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
url: input.url,
|
||||||
|
events: input.events,
|
||||||
|
secret,
|
||||||
|
description: input.description,
|
||||||
|
headers: input.headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.webhookRepository.save(webhook);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Webhook created: ${webhook.id} for organization ${input.organizationId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get webhook by ID
|
||||||
|
*/
|
||||||
|
async getWebhookById(id: string): Promise<Webhook | null> {
|
||||||
|
return this.webhookRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get webhooks by organization
|
||||||
|
*/
|
||||||
|
async getWebhooksByOrganization(organizationId: string): Promise<Webhook[]> {
|
||||||
|
return this.webhookRepository.findByOrganization(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get webhooks with filters
|
||||||
|
*/
|
||||||
|
async getWebhooks(filters: WebhookFilters): Promise<Webhook[]> {
|
||||||
|
return this.webhookRepository.findByFilters(filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update webhook
|
||||||
|
*/
|
||||||
|
async updateWebhook(id: string, updates: UpdateWebhookInput): Promise<Webhook> {
|
||||||
|
const webhook = await this.webhookRepository.findById(id);
|
||||||
|
if (!webhook) {
|
||||||
|
throw new Error('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedWebhook = webhook.update(updates);
|
||||||
|
await this.webhookRepository.save(updatedWebhook);
|
||||||
|
|
||||||
|
this.logger.log(`Webhook updated: ${id}`);
|
||||||
|
|
||||||
|
return updatedWebhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate webhook
|
||||||
|
*/
|
||||||
|
async activateWebhook(id: string): Promise<void> {
|
||||||
|
const webhook = await this.webhookRepository.findById(id);
|
||||||
|
if (!webhook) {
|
||||||
|
throw new Error('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activatedWebhook = webhook.activate();
|
||||||
|
await this.webhookRepository.save(activatedWebhook);
|
||||||
|
|
||||||
|
this.logger.log(`Webhook activated: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate webhook
|
||||||
|
*/
|
||||||
|
async deactivateWebhook(id: string): Promise<void> {
|
||||||
|
const webhook = await this.webhookRepository.findById(id);
|
||||||
|
if (!webhook) {
|
||||||
|
throw new Error('Webhook not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivatedWebhook = webhook.deactivate();
|
||||||
|
await this.webhookRepository.save(deactivatedWebhook);
|
||||||
|
|
||||||
|
this.logger.log(`Webhook deactivated: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete webhook
|
||||||
|
*/
|
||||||
|
async deleteWebhook(id: string): Promise<void> {
|
||||||
|
await this.webhookRepository.delete(id);
|
||||||
|
this.logger.log(`Webhook deleted: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger webhooks for an event
|
||||||
|
*/
|
||||||
|
async triggerWebhooks(
|
||||||
|
event: WebhookEvent,
|
||||||
|
organizationId: string,
|
||||||
|
data: any,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const webhooks = await this.webhookRepository.findActiveByEvent(event, organizationId);
|
||||||
|
|
||||||
|
if (webhooks.length === 0) {
|
||||||
|
this.logger.debug(`No active webhooks found for event: ${event}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: WebhookPayload = {
|
||||||
|
event,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
data,
|
||||||
|
organizationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trigger all webhooks in parallel
|
||||||
|
await Promise.allSettled(
|
||||||
|
webhooks.map((webhook) => this.triggerWebhook(webhook, payload)),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Triggered ${webhooks.length} webhooks for event: ${event}`,
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error triggering webhooks: ${error?.message || 'Unknown error'}`,
|
||||||
|
error?.stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a single webhook with retries
|
||||||
|
*/
|
||||||
|
private async triggerWebhook(
|
||||||
|
webhook: Webhook,
|
||||||
|
payload: WebhookPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
if (attempt > 0) {
|
||||||
|
await this.delay(this.RETRY_DELAY_MS * attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate signature
|
||||||
|
const signature = this.generateSignature(payload, webhook.secret);
|
||||||
|
|
||||||
|
// Prepare headers
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Webhook-Signature': signature,
|
||||||
|
'X-Webhook-Event': payload.event,
|
||||||
|
'X-Webhook-Timestamp': payload.timestamp,
|
||||||
|
...webhook.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send HTTP request
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(webhook.url, payload, {
|
||||||
|
headers,
|
||||||
|
timeout: 10000, // 10 seconds
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response && response.status >= 200 && response.status < 300) {
|
||||||
|
// Success - record trigger
|
||||||
|
const updatedWebhook = webhook.recordTrigger();
|
||||||
|
await this.webhookRepository.save(updatedWebhook);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Webhook triggered successfully: ${webhook.id} (attempt ${attempt + 1})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = new Error(`HTTP ${response?.status || 'Unknown'}: ${response?.statusText || 'Unknown error'}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error;
|
||||||
|
this.logger.warn(
|
||||||
|
`Webhook trigger attempt ${attempt + 1} failed: ${webhook.id} - ${error?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries failed - mark webhook as failed
|
||||||
|
const failedWebhook = webhook.markAsFailed();
|
||||||
|
await this.webhookRepository.save(failedWebhook);
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`Webhook failed after ${this.MAX_RETRIES} attempts: ${webhook.id} - ${lastError?.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate webhook secret
|
||||||
|
*/
|
||||||
|
private generateSecret(): string {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HMAC signature for webhook payload
|
||||||
|
*/
|
||||||
|
private generateSignature(payload: any, secret: string): string {
|
||||||
|
const hmac = crypto.createHmac('sha256', secret);
|
||||||
|
hmac.update(JSON.stringify(payload));
|
||||||
|
return hmac.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify webhook signature
|
||||||
|
*/
|
||||||
|
verifySignature(payload: any, signature: string, secret: string): boolean {
|
||||||
|
const expectedSignature = this.generateSignature(payload, secret);
|
||||||
|
return crypto.timingSafeEqual(
|
||||||
|
Buffer.from(signature),
|
||||||
|
Buffer.from(expectedSignature),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay helper for retries
|
||||||
|
*/
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/backend/src/application/webhooks/webhooks.module.ts
Normal file
34
apps/backend/src/application/webhooks/webhooks.module.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Webhooks Module
|
||||||
|
*
|
||||||
|
* Provides webhook functionality for external integrations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { WebhooksController } from '../controllers/webhooks.controller';
|
||||||
|
import { WebhookService } from '../services/webhook.service';
|
||||||
|
import { WebhookOrmEntity } from '../../infrastructure/persistence/typeorm/entities/webhook.orm-entity';
|
||||||
|
import { TypeOrmWebhookRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-webhook.repository';
|
||||||
|
import { WEBHOOK_REPOSITORY } from '../../domain/ports/out/webhook.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([WebhookOrmEntity]),
|
||||||
|
HttpModule.register({
|
||||||
|
timeout: 10000,
|
||||||
|
maxRedirects: 5,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [WebhooksController],
|
||||||
|
providers: [
|
||||||
|
WebhookService,
|
||||||
|
{
|
||||||
|
provide: WEBHOOK_REPOSITORY,
|
||||||
|
useClass: TypeOrmWebhookRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [WebhookService],
|
||||||
|
})
|
||||||
|
export class WebhooksModule {}
|
||||||
174
apps/backend/src/domain/entities/audit-log.entity.ts
Normal file
174
apps/backend/src/domain/entities/audit-log.entity.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* AuditLog Entity
|
||||||
|
*
|
||||||
|
* Tracks all important actions in the system for security and compliance
|
||||||
|
*
|
||||||
|
* Business Rules:
|
||||||
|
* - Every sensitive action must be logged
|
||||||
|
* - Audit logs are immutable (cannot be edited or deleted)
|
||||||
|
* - Must capture user, action, resource, and timestamp
|
||||||
|
* - Support filtering and searching for compliance audits
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum AuditAction {
|
||||||
|
// Booking actions
|
||||||
|
BOOKING_CREATED = 'booking_created',
|
||||||
|
BOOKING_UPDATED = 'booking_updated',
|
||||||
|
BOOKING_CANCELLED = 'booking_cancelled',
|
||||||
|
BOOKING_STATUS_CHANGED = 'booking_status_changed',
|
||||||
|
|
||||||
|
// User actions
|
||||||
|
USER_LOGIN = 'user_login',
|
||||||
|
USER_LOGOUT = 'user_logout',
|
||||||
|
USER_CREATED = 'user_created',
|
||||||
|
USER_UPDATED = 'user_updated',
|
||||||
|
USER_DELETED = 'user_deleted',
|
||||||
|
USER_ROLE_CHANGED = 'user_role_changed',
|
||||||
|
|
||||||
|
// Organization actions
|
||||||
|
ORGANIZATION_CREATED = 'organization_created',
|
||||||
|
ORGANIZATION_UPDATED = 'organization_updated',
|
||||||
|
|
||||||
|
// Document actions
|
||||||
|
DOCUMENT_UPLOADED = 'document_uploaded',
|
||||||
|
DOCUMENT_DOWNLOADED = 'document_downloaded',
|
||||||
|
DOCUMENT_DELETED = 'document_deleted',
|
||||||
|
|
||||||
|
// Rate actions
|
||||||
|
RATE_SEARCHED = 'rate_searched',
|
||||||
|
|
||||||
|
// Export actions
|
||||||
|
DATA_EXPORTED = 'data_exported',
|
||||||
|
|
||||||
|
// Settings actions
|
||||||
|
SETTINGS_UPDATED = 'settings_updated',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AuditStatus {
|
||||||
|
SUCCESS = 'success',
|
||||||
|
FAILURE = 'failure',
|
||||||
|
WARNING = 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogProps {
|
||||||
|
id: string;
|
||||||
|
action: AuditAction;
|
||||||
|
status: AuditStatus;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
organizationId: string;
|
||||||
|
resourceType?: string; // e.g., 'booking', 'user', 'document'
|
||||||
|
resourceId?: string;
|
||||||
|
resourceName?: string;
|
||||||
|
metadata?: Record<string, any>; // Additional context
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuditLog {
|
||||||
|
private readonly props: AuditLogProps;
|
||||||
|
|
||||||
|
private constructor(props: AuditLogProps) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a new audit log entry
|
||||||
|
*/
|
||||||
|
static create(props: Omit<AuditLogProps, 'id' | 'timestamp'> & { id: string }): AuditLog {
|
||||||
|
return new AuditLog({
|
||||||
|
...props,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to reconstitute from persistence
|
||||||
|
*/
|
||||||
|
static fromPersistence(props: AuditLogProps): AuditLog {
|
||||||
|
return new AuditLog(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get id(): string {
|
||||||
|
return this.props.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get action(): AuditAction {
|
||||||
|
return this.props.action;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): AuditStatus {
|
||||||
|
return this.props.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userId(): string {
|
||||||
|
return this.props.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userEmail(): string {
|
||||||
|
return this.props.userEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
get organizationId(): string {
|
||||||
|
return this.props.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get resourceType(): string | undefined {
|
||||||
|
return this.props.resourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
get resourceId(): string | undefined {
|
||||||
|
return this.props.resourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get resourceName(): string | undefined {
|
||||||
|
return this.props.resourceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
get metadata(): Record<string, any> | undefined {
|
||||||
|
return this.props.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ipAddress(): string | undefined {
|
||||||
|
return this.props.ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userAgent(): string | undefined {
|
||||||
|
return this.props.userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
get errorMessage(): string | undefined {
|
||||||
|
return this.props.errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
get timestamp(): Date {
|
||||||
|
return this.props.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if action was successful
|
||||||
|
*/
|
||||||
|
isSuccessful(): boolean {
|
||||||
|
return this.props.status === AuditStatus.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if action failed
|
||||||
|
*/
|
||||||
|
isFailed(): boolean {
|
||||||
|
return this.props.status === AuditStatus.FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to plain object
|
||||||
|
*/
|
||||||
|
toObject(): AuditLogProps {
|
||||||
|
return {
|
||||||
|
...this.props,
|
||||||
|
metadata: this.props.metadata ? { ...this.props.metadata } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
140
apps/backend/src/domain/entities/notification.entity.ts
Normal file
140
apps/backend/src/domain/entities/notification.entity.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Notification Entity
|
||||||
|
*
|
||||||
|
* Represents a notification sent to a user
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum NotificationType {
|
||||||
|
BOOKING_CREATED = 'booking_created',
|
||||||
|
BOOKING_UPDATED = 'booking_updated',
|
||||||
|
BOOKING_CANCELLED = 'booking_cancelled',
|
||||||
|
BOOKING_CONFIRMED = 'booking_confirmed',
|
||||||
|
RATE_QUOTE_EXPIRING = 'rate_quote_expiring',
|
||||||
|
DOCUMENT_UPLOADED = 'document_uploaded',
|
||||||
|
SYSTEM_ANNOUNCEMENT = 'system_announcement',
|
||||||
|
USER_INVITED = 'user_invited',
|
||||||
|
ORGANIZATION_UPDATE = 'organization_update',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NotificationPriority {
|
||||||
|
LOW = 'low',
|
||||||
|
MEDIUM = 'medium',
|
||||||
|
HIGH = 'high',
|
||||||
|
URGENT = 'urgent',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationProps {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
organizationId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
priority: NotificationPriority;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
read: boolean;
|
||||||
|
readAt?: Date;
|
||||||
|
actionUrl?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Notification {
|
||||||
|
private constructor(private readonly props: NotificationProps) {}
|
||||||
|
|
||||||
|
static create(
|
||||||
|
props: Omit<NotificationProps, 'id' | 'read' | 'createdAt'> & { id: string },
|
||||||
|
): Notification {
|
||||||
|
return new Notification({
|
||||||
|
...props,
|
||||||
|
read: false,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPersistence(props: NotificationProps): Notification {
|
||||||
|
return new Notification(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string {
|
||||||
|
return this.props.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userId(): string {
|
||||||
|
return this.props.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get organizationId(): string {
|
||||||
|
return this.props.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get type(): NotificationType {
|
||||||
|
return this.props.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
get priority(): NotificationPriority {
|
||||||
|
return this.props.priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
get title(): string {
|
||||||
|
return this.props.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
get message(): string {
|
||||||
|
return this.props.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
get metadata(): Record<string, any> | undefined {
|
||||||
|
return this.props.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
get read(): boolean {
|
||||||
|
return this.props.read;
|
||||||
|
}
|
||||||
|
|
||||||
|
get readAt(): Date | undefined {
|
||||||
|
return this.props.readAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get actionUrl(): string | undefined {
|
||||||
|
return this.props.actionUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get createdAt(): Date {
|
||||||
|
return this.props.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notification as read
|
||||||
|
*/
|
||||||
|
markAsRead(): Notification {
|
||||||
|
return new Notification({
|
||||||
|
...this.props,
|
||||||
|
read: true,
|
||||||
|
readAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notification is unread
|
||||||
|
*/
|
||||||
|
isUnread(): boolean {
|
||||||
|
return !this.props.read;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if notification is high priority
|
||||||
|
*/
|
||||||
|
isHighPriority(): boolean {
|
||||||
|
return (
|
||||||
|
this.props.priority === NotificationPriority.HIGH ||
|
||||||
|
this.props.priority === NotificationPriority.URGENT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to plain object
|
||||||
|
*/
|
||||||
|
toObject(): NotificationProps {
|
||||||
|
return { ...this.props };
|
||||||
|
}
|
||||||
|
}
|
||||||
195
apps/backend/src/domain/entities/webhook.entity.ts
Normal file
195
apps/backend/src/domain/entities/webhook.entity.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Webhook Entity
|
||||||
|
*
|
||||||
|
* Represents a webhook subscription for external integrations
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum WebhookEvent {
|
||||||
|
BOOKING_CREATED = 'booking.created',
|
||||||
|
BOOKING_UPDATED = 'booking.updated',
|
||||||
|
BOOKING_CANCELLED = 'booking.cancelled',
|
||||||
|
BOOKING_CONFIRMED = 'booking.confirmed',
|
||||||
|
RATE_QUOTE_CREATED = 'rate_quote.created',
|
||||||
|
DOCUMENT_UPLOADED = 'document.uploaded',
|
||||||
|
ORGANIZATION_UPDATED = 'organization.updated',
|
||||||
|
USER_CREATED = 'user.created',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum WebhookStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
FAILED = 'failed',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookProps {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
url: string;
|
||||||
|
events: WebhookEvent[];
|
||||||
|
secret: string;
|
||||||
|
status: WebhookStatus;
|
||||||
|
description?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
retryCount: number;
|
||||||
|
lastTriggeredAt?: Date;
|
||||||
|
failureCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Webhook {
|
||||||
|
private constructor(private readonly props: WebhookProps) {}
|
||||||
|
|
||||||
|
static create(
|
||||||
|
props: Omit<WebhookProps, 'id' | 'status' | 'retryCount' | 'failureCount' | 'createdAt' | 'updatedAt'> & { id: string },
|
||||||
|
): Webhook {
|
||||||
|
return new Webhook({
|
||||||
|
...props,
|
||||||
|
status: WebhookStatus.ACTIVE,
|
||||||
|
retryCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPersistence(props: WebhookProps): Webhook {
|
||||||
|
return new Webhook(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): string {
|
||||||
|
return this.props.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get organizationId(): string {
|
||||||
|
return this.props.organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get url(): string {
|
||||||
|
return this.props.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
get events(): WebhookEvent[] {
|
||||||
|
return this.props.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
get secret(): string {
|
||||||
|
return this.props.secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): WebhookStatus {
|
||||||
|
return this.props.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get description(): string | undefined {
|
||||||
|
return this.props.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
get headers(): Record<string, string> | undefined {
|
||||||
|
return this.props.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
get retryCount(): number {
|
||||||
|
return this.props.retryCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get lastTriggeredAt(): Date | undefined {
|
||||||
|
return this.props.lastTriggeredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get failureCount(): number {
|
||||||
|
return this.props.failureCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get createdAt(): Date {
|
||||||
|
return this.props.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this.props.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if webhook is active
|
||||||
|
*/
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.props.status === WebhookStatus.ACTIVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if webhook subscribes to an event
|
||||||
|
*/
|
||||||
|
subscribesToEvent(event: WebhookEvent): boolean {
|
||||||
|
return this.props.events.includes(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate webhook
|
||||||
|
*/
|
||||||
|
activate(): Webhook {
|
||||||
|
return new Webhook({
|
||||||
|
...this.props,
|
||||||
|
status: WebhookStatus.ACTIVE,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate webhook
|
||||||
|
*/
|
||||||
|
deactivate(): Webhook {
|
||||||
|
return new Webhook({
|
||||||
|
...this.props,
|
||||||
|
status: WebhookStatus.INACTIVE,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark webhook as failed
|
||||||
|
*/
|
||||||
|
markAsFailed(): Webhook {
|
||||||
|
return new Webhook({
|
||||||
|
...this.props,
|
||||||
|
status: WebhookStatus.FAILED,
|
||||||
|
failureCount: this.props.failureCount + 1,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record successful trigger
|
||||||
|
*/
|
||||||
|
recordTrigger(): Webhook {
|
||||||
|
return new Webhook({
|
||||||
|
...this.props,
|
||||||
|
lastTriggeredAt: new Date(),
|
||||||
|
retryCount: this.props.retryCount + 1,
|
||||||
|
failureCount: 0, // Reset failure count on success
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update webhook
|
||||||
|
*/
|
||||||
|
update(updates: {
|
||||||
|
url?: string;
|
||||||
|
events?: WebhookEvent[];
|
||||||
|
description?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}): Webhook {
|
||||||
|
return new Webhook({
|
||||||
|
...this.props,
|
||||||
|
...updates,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to plain object
|
||||||
|
*/
|
||||||
|
toObject(): WebhookProps {
|
||||||
|
return { ...this.props };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
import { Entity, Column, PrimaryColumn, Index, CreateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('audit_logs')
|
||||||
|
@Index(['organization_id', 'timestamp'])
|
||||||
|
@Index(['user_id', 'timestamp'])
|
||||||
|
@Index(['resource_type', 'resource_id'])
|
||||||
|
@Index(['action'])
|
||||||
|
export class AuditLogOrmEntity {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
})
|
||||||
|
action: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 20,
|
||||||
|
})
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column('uuid')
|
||||||
|
@Index()
|
||||||
|
user_id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 255,
|
||||||
|
})
|
||||||
|
user_email: string;
|
||||||
|
|
||||||
|
@Column('uuid')
|
||||||
|
@Index()
|
||||||
|
organization_id: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
resource_type?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'uuid',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
resource_id?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
resource_name?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'jsonb',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 45,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
ip_address?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
user_agent?: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
error_message?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({
|
||||||
|
type: 'timestamp with time zone',
|
||||||
|
})
|
||||||
|
@Index()
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Notification ORM Entity
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('notifications')
|
||||||
|
@Index(['user_id', 'read', 'created_at'])
|
||||||
|
@Index(['organization_id', 'created_at'])
|
||||||
|
@Index(['user_id', 'created_at'])
|
||||||
|
export class NotificationOrmEntity {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column('uuid')
|
||||||
|
user_id: string;
|
||||||
|
|
||||||
|
@Column('uuid')
|
||||||
|
organization_id: string;
|
||||||
|
|
||||||
|
@Column('varchar', { length: 50 })
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
@Column('varchar', { length: 20 })
|
||||||
|
priority: string;
|
||||||
|
|
||||||
|
@Column('varchar', { length: 255 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@Column('jsonb', { nullable: true })
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
@Column('boolean', { default: false })
|
||||||
|
read: boolean;
|
||||||
|
|
||||||
|
@Column('timestamp', { nullable: true })
|
||||||
|
read_at?: Date;
|
||||||
|
|
||||||
|
@Column('varchar', { length: 500, nullable: true })
|
||||||
|
action_url?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Webhook ORM Entity
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('webhooks')
|
||||||
|
@Index(['organization_id', 'status'])
|
||||||
|
export class WebhookOrmEntity {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column('uuid')
|
||||||
|
organization_id: string;
|
||||||
|
|
||||||
|
@Column('varchar', { length: 500 })
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
@Column('simple-array')
|
||||||
|
events: string[];
|
||||||
|
|
||||||
|
@Column('varchar', { length: 255 })
|
||||||
|
secret: string;
|
||||||
|
|
||||||
|
@Column('varchar', { length: 20 })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Column('jsonb', { nullable: true })
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
|
||||||
|
@Column('int', { default: 0 })
|
||||||
|
retry_count: number;
|
||||||
|
|
||||||
|
@Column('timestamp', { nullable: true })
|
||||||
|
last_triggered_at?: Date;
|
||||||
|
|
||||||
|
@Column('int', { default: 0 })
|
||||||
|
failure_count: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
created_at: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class EnableFuzzySearch1700000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Enable pg_trgm extension for trigram similarity search
|
||||||
|
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm;`);
|
||||||
|
|
||||||
|
// Create GIN indexes for full-text search on bookings
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_booking_number_trgm
|
||||||
|
ON bookings USING gin(booking_number gin_trgm_ops);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipper_name_trgm
|
||||||
|
ON bookings USING gin(shipper_name gin_trgm_ops);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consignee_name_trgm
|
||||||
|
ON bookings USING gin(consignee_name gin_trgm_ops);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create full-text search indexes using ts_vector
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_booking_number_fts
|
||||||
|
ON bookings USING gin(to_tsvector('english', booking_number));
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipper_name_fts
|
||||||
|
ON bookings USING gin(to_tsvector('english', shipper_name));
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consignee_name_fts
|
||||||
|
ON bookings USING gin(to_tsvector('english', consignee_name));
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Drop indexes
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_booking_number_trgm;`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_shipper_name_trgm;`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_consignee_name_trgm;`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_booking_number_fts;`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_shipper_name_fts;`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_consignee_name_fts;`);
|
||||||
|
|
||||||
|
// Note: We don't drop the pg_trgm extension as other parts of the system might use it
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateAuditLogsTable1700000001000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'audit_logs',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '100',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '20',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user_id',
|
||||||
|
type: 'uuid',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user_email',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '255',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'organization_id',
|
||||||
|
type: 'uuid',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resource_type',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '100',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resource_id',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '255',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'resource_name',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '255',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'metadata',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ip_address',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '45',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user_agent',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'error_message',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'timestamp',
|
||||||
|
type: 'timestamp',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create indexes for efficient querying
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'audit_logs',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_audit_logs_organization_timestamp',
|
||||||
|
columnNames: ['organization_id', 'timestamp'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'audit_logs',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_audit_logs_user_timestamp',
|
||||||
|
columnNames: ['user_id', 'timestamp'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'audit_logs',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_audit_logs_resource',
|
||||||
|
columnNames: ['resource_type', 'resource_id'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'audit_logs',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_audit_logs_action',
|
||||||
|
columnNames: ['action'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'audit_logs',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_audit_logs_timestamp',
|
||||||
|
columnNames: ['timestamp'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('audit_logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateNotificationsTable1700000002000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'notifications',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user_id',
|
||||||
|
type: 'uuid',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'organization_id',
|
||||||
|
type: 'uuid',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'type',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '50',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'priority',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '20',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '255',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'message',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'metadata',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'read',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'read_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'action_url',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '500',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'created_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create indexes for efficient querying
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'notifications',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_notifications_user_read_created',
|
||||||
|
columnNames: ['user_id', 'read', 'created_at'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'notifications',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_notifications_organization_created',
|
||||||
|
columnNames: ['organization_id', 'created_at'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'notifications',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_notifications_user_created',
|
||||||
|
columnNames: ['user_id', 'created_at'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('notifications');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateWebhooksTable1700000003000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'webhooks',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'organization_id',
|
||||||
|
type: 'uuid',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'url',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '500',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'events',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'secret',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '255',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '20',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
type: 'text',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'headers',
|
||||||
|
type: 'jsonb',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'retry_count',
|
||||||
|
type: 'int',
|
||||||
|
default: 0,
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'last_triggered_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'failure_count',
|
||||||
|
type: 'int',
|
||||||
|
default: 0,
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'created_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
default: 'CURRENT_TIMESTAMP',
|
||||||
|
isNullable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create index for efficient querying
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'webhooks',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'idx_webhooks_organization_status',
|
||||||
|
columnNames: ['organization_id', 'status'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('webhooks');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* TypeORM Audit Log Repository Implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, In, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
|
||||||
|
import {
|
||||||
|
AuditLogRepository,
|
||||||
|
AuditLogFilters,
|
||||||
|
} from '../../../../domain/ports/out/audit-log.repository';
|
||||||
|
import { AuditLog, AuditStatus, AuditAction } from '../../../../domain/entities/audit-log.entity';
|
||||||
|
import { AuditLogOrmEntity } from '../entities/audit-log.orm-entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TypeOrmAuditLogRepository implements AuditLogRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AuditLogOrmEntity)
|
||||||
|
private readonly ormRepository: Repository<AuditLogOrmEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async save(auditLog: AuditLog): Promise<void> {
|
||||||
|
const ormEntity = this.toOrm(auditLog);
|
||||||
|
await this.ormRepository.save(ormEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<AuditLog | null> {
|
||||||
|
const ormEntity = await this.ormRepository.findOne({ where: { id } });
|
||||||
|
return ormEntity ? this.toDomain(ormEntity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByFilters(filters: AuditLogFilters): Promise<AuditLog[]> {
|
||||||
|
const query = this.ormRepository.createQueryBuilder('audit_log');
|
||||||
|
|
||||||
|
if (filters.userId) {
|
||||||
|
query.andWhere('audit_log.user_id = :userId', { userId: filters.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.organizationId) {
|
||||||
|
query.andWhere('audit_log.organization_id = :organizationId', {
|
||||||
|
organizationId: filters.organizationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.action && filters.action.length > 0) {
|
||||||
|
query.andWhere('audit_log.action IN (:...actions)', { actions: filters.action });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.resourceType) {
|
||||||
|
query.andWhere('audit_log.resource_type = :resourceType', {
|
||||||
|
resourceType: filters.resourceType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.resourceId) {
|
||||||
|
query.andWhere('audit_log.resource_id = :resourceId', {
|
||||||
|
resourceId: filters.resourceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateFrom) {
|
||||||
|
query.andWhere('audit_log.timestamp >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateTo) {
|
||||||
|
query.andWhere('audit_log.timestamp <= :dateTo', { dateTo: filters.dateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('audit_log.timestamp', 'DESC');
|
||||||
|
|
||||||
|
if (filters.limit) {
|
||||||
|
query.limit(filters.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.offset) {
|
||||||
|
query.offset(filters.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ormEntities = await query.getMany();
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(filters: AuditLogFilters): Promise<number> {
|
||||||
|
const query = this.ormRepository.createQueryBuilder('audit_log');
|
||||||
|
|
||||||
|
if (filters.userId) {
|
||||||
|
query.andWhere('audit_log.user_id = :userId', { userId: filters.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.organizationId) {
|
||||||
|
query.andWhere('audit_log.organization_id = :organizationId', {
|
||||||
|
organizationId: filters.organizationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.action && filters.action.length > 0) {
|
||||||
|
query.andWhere('audit_log.action IN (:...actions)', { actions: filters.action });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.resourceType) {
|
||||||
|
query.andWhere('audit_log.resource_type = :resourceType', {
|
||||||
|
resourceType: filters.resourceType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.resourceId) {
|
||||||
|
query.andWhere('audit_log.resource_id = :resourceId', {
|
||||||
|
resourceId: filters.resourceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateFrom) {
|
||||||
|
query.andWhere('audit_log.timestamp >= :dateFrom', { dateFrom: filters.dateFrom });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.dateTo) {
|
||||||
|
query.andWhere('audit_log.timestamp <= :dateTo', { dateTo: filters.dateTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByResource(resourceType: string, resourceId: string): Promise<AuditLog[]> {
|
||||||
|
const ormEntities = await this.ormRepository.find({
|
||||||
|
where: {
|
||||||
|
resource_type: resourceType,
|
||||||
|
resource_id: resourceId,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
timestamp: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRecentByOrganization(organizationId: string, limit: number): Promise<AuditLog[]> {
|
||||||
|
const ormEntities = await this.ormRepository.find({
|
||||||
|
where: {
|
||||||
|
organization_id: organizationId,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
timestamp: 'DESC',
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUser(userId: string, limit: number): Promise<AuditLog[]> {
|
||||||
|
const ormEntities = await this.ormRepository.find({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
timestamp: 'DESC',
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map ORM entity to domain entity
|
||||||
|
*/
|
||||||
|
private toDomain(ormEntity: AuditLogOrmEntity): AuditLog {
|
||||||
|
return AuditLog.fromPersistence({
|
||||||
|
id: ormEntity.id,
|
||||||
|
action: ormEntity.action as AuditAction,
|
||||||
|
status: ormEntity.status as AuditStatus,
|
||||||
|
userId: ormEntity.user_id,
|
||||||
|
userEmail: ormEntity.user_email,
|
||||||
|
organizationId: ormEntity.organization_id,
|
||||||
|
resourceType: ormEntity.resource_type,
|
||||||
|
resourceId: ormEntity.resource_id,
|
||||||
|
resourceName: ormEntity.resource_name,
|
||||||
|
metadata: ormEntity.metadata,
|
||||||
|
ipAddress: ormEntity.ip_address,
|
||||||
|
userAgent: ormEntity.user_agent,
|
||||||
|
errorMessage: ormEntity.error_message,
|
||||||
|
timestamp: ormEntity.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map domain entity to ORM entity
|
||||||
|
*/
|
||||||
|
private toOrm(auditLog: AuditLog): AuditLogOrmEntity {
|
||||||
|
const ormEntity = new AuditLogOrmEntity();
|
||||||
|
ormEntity.id = auditLog.id;
|
||||||
|
ormEntity.action = auditLog.action;
|
||||||
|
ormEntity.status = auditLog.status;
|
||||||
|
ormEntity.user_id = auditLog.userId;
|
||||||
|
ormEntity.user_email = auditLog.userEmail;
|
||||||
|
ormEntity.organization_id = auditLog.organizationId;
|
||||||
|
ormEntity.resource_type = auditLog.resourceType;
|
||||||
|
ormEntity.resource_id = auditLog.resourceId;
|
||||||
|
ormEntity.resource_name = auditLog.resourceName;
|
||||||
|
ormEntity.metadata = auditLog.metadata;
|
||||||
|
ormEntity.ip_address = auditLog.ipAddress;
|
||||||
|
ormEntity.user_agent = auditLog.userAgent;
|
||||||
|
ormEntity.error_message = auditLog.errorMessage;
|
||||||
|
ormEntity.timestamp = auditLog.timestamp;
|
||||||
|
return ormEntity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* TypeORM Notification Repository Implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, LessThan } from 'typeorm';
|
||||||
|
import {
|
||||||
|
NotificationRepository,
|
||||||
|
NotificationFilters,
|
||||||
|
} from '../../../../domain/ports/out/notification.repository';
|
||||||
|
import { Notification } from '../../../../domain/entities/notification.entity';
|
||||||
|
import { NotificationOrmEntity } from '../entities/notification.orm-entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TypeOrmNotificationRepository implements NotificationRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(NotificationOrmEntity)
|
||||||
|
private readonly ormRepository: Repository<NotificationOrmEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async save(notification: Notification): Promise<void> {
|
||||||
|
const ormEntity = this.toOrm(notification);
|
||||||
|
await this.ormRepository.save(ormEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Notification | null> {
|
||||||
|
const ormEntity = await this.ormRepository.findOne({ where: { id } });
|
||||||
|
return ormEntity ? this.toDomain(ormEntity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByFilters(filters: NotificationFilters): Promise<Notification[]> {
|
||||||
|
const query = this.ormRepository.createQueryBuilder('notification');
|
||||||
|
|
||||||
|
if (filters.userId) {
|
||||||
|
query.andWhere('notification.user_id = :userId', { userId: filters.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.organizationId) {
|
||||||
|
query.andWhere('notification.organization_id = :organizationId', {
|
||||||
|
organizationId: filters.organizationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.type && filters.type.length > 0) {
|
||||||
|
query.andWhere('notification.type IN (:...types)', { types: filters.type });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.read !== undefined) {
|
||||||
|
query.andWhere('notification.read = :read', { read: filters.read });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.priority && filters.priority.length > 0) {
|
||||||
|
query.andWhere('notification.priority IN (:...priorities)', {
|
||||||
|
priorities: filters.priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
query.andWhere('notification.created_at >= :startDate', {
|
||||||
|
startDate: filters.startDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
query.andWhere('notification.created_at <= :endDate', {
|
||||||
|
endDate: filters.endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('notification.created_at', 'DESC');
|
||||||
|
|
||||||
|
if (filters.offset) {
|
||||||
|
query.skip(filters.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.limit) {
|
||||||
|
query.take(filters.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ormEntities = await query.getMany();
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async count(filters: NotificationFilters): Promise<number> {
|
||||||
|
const query = this.ormRepository.createQueryBuilder('notification');
|
||||||
|
|
||||||
|
if (filters.userId) {
|
||||||
|
query.andWhere('notification.user_id = :userId', { userId: filters.userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.organizationId) {
|
||||||
|
query.andWhere('notification.organization_id = :organizationId', {
|
||||||
|
organizationId: filters.organizationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.type && filters.type.length > 0) {
|
||||||
|
query.andWhere('notification.type IN (:...types)', { types: filters.type });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.read !== undefined) {
|
||||||
|
query.andWhere('notification.read = :read', { read: filters.read });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.priority && filters.priority.length > 0) {
|
||||||
|
query.andWhere('notification.priority IN (:...priorities)', {
|
||||||
|
priorities: filters.priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.startDate) {
|
||||||
|
query.andWhere('notification.created_at >= :startDate', {
|
||||||
|
startDate: filters.startDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.endDate) {
|
||||||
|
query.andWhere('notification.created_at <= :endDate', {
|
||||||
|
endDate: filters.endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUnreadByUser(userId: string, limit: number = 50): Promise<Notification[]> {
|
||||||
|
const ormEntities = await this.ormRepository.find({
|
||||||
|
where: { user_id: userId, read: false },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async countUnreadByUser(userId: string): Promise<number> {
|
||||||
|
return this.ormRepository.count({
|
||||||
|
where: { user_id: userId, read: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRecentByUser(userId: string, limit: number = 50): Promise<Notification[]> {
|
||||||
|
const ormEntities = await this.ormRepository.find({
|
||||||
|
where: { user_id: userId },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAsRead(id: string): Promise<void> {
|
||||||
|
await this.ormRepository.update(id, {
|
||||||
|
read: true,
|
||||||
|
read_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllAsReadForUser(userId: string): Promise<void> {
|
||||||
|
await this.ormRepository.update(
|
||||||
|
{ user_id: userId, read: false },
|
||||||
|
{
|
||||||
|
read: true,
|
||||||
|
read_at: new Date(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.ormRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOldReadNotifications(olderThanDays: number): Promise<number> {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||||
|
|
||||||
|
const result = await this.ormRepository.delete({
|
||||||
|
read: true,
|
||||||
|
read_at: LessThan(cutoffDate),
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.affected || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(ormEntity: NotificationOrmEntity): Notification {
|
||||||
|
return Notification.fromPersistence({
|
||||||
|
id: ormEntity.id,
|
||||||
|
userId: ormEntity.user_id,
|
||||||
|
organizationId: ormEntity.organization_id,
|
||||||
|
type: ormEntity.type as any,
|
||||||
|
priority: ormEntity.priority as any,
|
||||||
|
title: ormEntity.title,
|
||||||
|
message: ormEntity.message,
|
||||||
|
metadata: ormEntity.metadata,
|
||||||
|
read: ormEntity.read,
|
||||||
|
readAt: ormEntity.read_at,
|
||||||
|
actionUrl: ormEntity.action_url,
|
||||||
|
createdAt: ormEntity.created_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOrm(notification: Notification): NotificationOrmEntity {
|
||||||
|
const ormEntity = new NotificationOrmEntity();
|
||||||
|
ormEntity.id = notification.id;
|
||||||
|
ormEntity.user_id = notification.userId;
|
||||||
|
ormEntity.organization_id = notification.organizationId;
|
||||||
|
ormEntity.type = notification.type;
|
||||||
|
ormEntity.priority = notification.priority;
|
||||||
|
ormEntity.title = notification.title;
|
||||||
|
ormEntity.message = notification.message;
|
||||||
|
ormEntity.metadata = notification.metadata;
|
||||||
|
ormEntity.read = notification.read;
|
||||||
|
ormEntity.read_at = notification.readAt;
|
||||||
|
ormEntity.action_url = notification.actionUrl;
|
||||||
|
ormEntity.created_at = notification.createdAt;
|
||||||
|
return ormEntity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* TypeORM Webhook Repository Implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
WebhookRepository,
|
||||||
|
WebhookFilters,
|
||||||
|
} from '../../../../domain/ports/out/webhook.repository';
|
||||||
|
import { Webhook, WebhookEvent, WebhookStatus } from '../../../../domain/entities/webhook.entity';
|
||||||
|
import { WebhookOrmEntity } from '../entities/webhook.orm-entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TypeOrmWebhookRepository implements WebhookRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(WebhookOrmEntity)
|
||||||
|
private readonly ormRepository: Repository<WebhookOrmEntity>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async save(webhook: Webhook): Promise<void> {
|
||||||
|
const ormEntity = this.toOrm(webhook);
|
||||||
|
await this.ormRepository.save(ormEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Webhook | null> {
|
||||||
|
const ormEntity = await this.ormRepository.findOne({ where: { id } });
|
||||||
|
return ormEntity ? this.toDomain(ormEntity) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByOrganization(organizationId: string): Promise<Webhook[]> {
|
||||||
|
const ormEntities = await this.ormRepository.find({
|
||||||
|
where: { organization_id: organizationId },
|
||||||
|
order: { created_at: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveByEvent(event: WebhookEvent, organizationId: string): Promise<Webhook[]> {
|
||||||
|
const ormEntities = await this.ormRepository
|
||||||
|
.createQueryBuilder('webhook')
|
||||||
|
.where('webhook.organization_id = :organizationId', { organizationId })
|
||||||
|
.andWhere('webhook.status = :status', { status: WebhookStatus.ACTIVE })
|
||||||
|
.andWhere(':event = ANY(webhook.events)', { event })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByFilters(filters: WebhookFilters): Promise<Webhook[]> {
|
||||||
|
const query = this.ormRepository.createQueryBuilder('webhook');
|
||||||
|
|
||||||
|
if (filters.organizationId) {
|
||||||
|
query.andWhere('webhook.organization_id = :organizationId', {
|
||||||
|
organizationId: filters.organizationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status && filters.status.length > 0) {
|
||||||
|
query.andWhere('webhook.status IN (:...statuses)', { statuses: filters.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.event) {
|
||||||
|
query.andWhere(':event = ANY(webhook.events)', { event: filters.event });
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('webhook.created_at', 'DESC');
|
||||||
|
|
||||||
|
const ormEntities = await query.getMany();
|
||||||
|
return ormEntities.map((e) => this.toDomain(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.ormRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countByOrganization(organizationId: string): Promise<number> {
|
||||||
|
return this.ormRepository.count({
|
||||||
|
where: { organization_id: organizationId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(ormEntity: WebhookOrmEntity): Webhook {
|
||||||
|
return Webhook.fromPersistence({
|
||||||
|
id: ormEntity.id,
|
||||||
|
organizationId: ormEntity.organization_id,
|
||||||
|
url: ormEntity.url,
|
||||||
|
events: ormEntity.events as WebhookEvent[],
|
||||||
|
secret: ormEntity.secret,
|
||||||
|
status: ormEntity.status as WebhookStatus,
|
||||||
|
description: ormEntity.description,
|
||||||
|
headers: ormEntity.headers,
|
||||||
|
retryCount: ormEntity.retry_count,
|
||||||
|
lastTriggeredAt: ormEntity.last_triggered_at,
|
||||||
|
failureCount: ormEntity.failure_count,
|
||||||
|
createdAt: ormEntity.created_at,
|
||||||
|
updatedAt: ormEntity.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOrm(webhook: Webhook): WebhookOrmEntity {
|
||||||
|
const ormEntity = new WebhookOrmEntity();
|
||||||
|
ormEntity.id = webhook.id;
|
||||||
|
ormEntity.organization_id = webhook.organizationId;
|
||||||
|
ormEntity.url = webhook.url;
|
||||||
|
ormEntity.events = webhook.events;
|
||||||
|
ormEntity.secret = webhook.secret;
|
||||||
|
ormEntity.status = webhook.status;
|
||||||
|
ormEntity.description = webhook.description;
|
||||||
|
ormEntity.headers = webhook.headers;
|
||||||
|
ormEntity.retry_count = webhook.retryCount;
|
||||||
|
ormEntity.last_triggered_at = webhook.lastTriggeredAt;
|
||||||
|
ormEntity.failure_count = webhook.failureCount;
|
||||||
|
ormEntity.created_at = webhook.createdAt;
|
||||||
|
ormEntity.updated_at = webhook.updatedAt;
|
||||||
|
return ormEntity;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
apps/frontend/package-lock.json
generated
192
apps/frontend/package-lock.json
generated
@ -16,9 +16,13 @@
|
|||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"next": "14.0.4",
|
"next": "14.0.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -27,6 +31,7 @@
|
|||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@ -34,6 +39,7 @@
|
|||||||
"@playwright/test": "^1.40.1",
|
"@playwright/test": "^1.40.1",
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@testing-library/react": "^14.1.2",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
@ -2424,6 +2430,66 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.13.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||||
|
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.13.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.13.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||||
|
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "9.3.4",
|
"version": "9.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
|
||||||
@ -2636,6 +2702,13 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/file-saver": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/graceful-fs": {
|
"node_modules/@types/graceful-fs": {
|
||||||
"version": "4.1.9",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
|
||||||
@ -3232,6 +3305,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@ -3984,6 +4066,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@ -4123,6 +4218,15 @@
|
|||||||
"node": ">= 0.12.0"
|
"node": ">= 0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/collect-v8-coverage": {
|
"node_modules/collect-v8-coverage": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
|
||||||
@ -4183,6 +4287,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/create-jest": {
|
"node_modules/create-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||||
@ -4469,6 +4585,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@ -5611,6 +5737,12 @@
|
|||||||
"node": "^10.12.0 || >=12.0.0"
|
"node": "^10.12.0 || >=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-saver": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@ -5742,6 +5874,15 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
@ -10031,6 +10172,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@ -11238,6 +11391,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@ -11327,6 +11498,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||||
|
|||||||
@ -21,9 +21,13 @@
|
|||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"next": "14.0.4",
|
"next": "14.0.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -32,6 +36,7 @@
|
|||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@ -39,6 +44,7 @@
|
|||||||
"@playwright/test": "^1.40.1",
|
"@playwright/test": "^1.40.1",
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@testing-library/react": "^14.1.2",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
|
|||||||
209
apps/frontend/src/components/admin/CarrierForm.tsx
Normal file
209
apps/frontend/src/components/admin/CarrierForm.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Carrier Form Component for Create/Edit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier';
|
||||||
|
|
||||||
|
interface CarrierFormProps {
|
||||||
|
carrier?: Carrier;
|
||||||
|
onSubmit: (data: CreateCarrierInput | UpdateCarrierInput) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CarrierForm: React.FC<CarrierFormProps> = ({
|
||||||
|
carrier,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: carrier?.name || '',
|
||||||
|
scac: carrier?.scac || '',
|
||||||
|
status: carrier?.status || CarrierStatus.ACTIVE,
|
||||||
|
apiEndpoint: carrier?.apiEndpoint || '',
|
||||||
|
apiKey: carrier?.apiKey || '',
|
||||||
|
priority: carrier?.priority?.toString() || '1',
|
||||||
|
rateLimit: carrier?.rateLimit?.toString() || '100',
|
||||||
|
timeout: carrier?.timeout?.toString() || '5000',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
name: formData.name,
|
||||||
|
scac: formData.scac,
|
||||||
|
status: formData.status,
|
||||||
|
apiEndpoint: formData.apiEndpoint || undefined,
|
||||||
|
apiKey: formData.apiKey || undefined,
|
||||||
|
priority: parseInt(formData.priority),
|
||||||
|
rateLimit: parseInt(formData.rateLimit),
|
||||||
|
timeout: parseInt(formData.timeout),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Carrier Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SCAC */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
SCAC Code <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={4}
|
||||||
|
value={formData.scac}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, scac: e.target.value.toUpperCase() })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, status: e.target.value as CarrierStatus })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value={CarrierStatus.ACTIVE}>Active</option>
|
||||||
|
<option value={CarrierStatus.INACTIVE}>Inactive</option>
|
||||||
|
<option value={CarrierStatus.MAINTENANCE}>Maintenance</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={formData.priority}
|
||||||
|
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Endpoint */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
API Endpoint
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.apiEndpoint}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, apiEndpoint: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="https://api.carrier.com/v1"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
API Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.apiKey}
|
||||||
|
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||||
|
placeholder="Enter API key"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rate Limit */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Rate Limit (req/min)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={formData.rateLimit}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rateLimit: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeout */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Timeout (ms)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1000"
|
||||||
|
step="1000"
|
||||||
|
value={formData.timeout}
|
||||||
|
onChange={(e) => setFormData({ ...formData, timeout: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-6 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Saving...' : carrier ? 'Update Carrier' : 'Create Carrier'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
apps/frontend/src/components/admin/index.ts
Normal file
5
apps/frontend/src/components/admin/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Admin Components Barrel Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { CarrierForm } from './CarrierForm';
|
||||||
246
apps/frontend/src/components/bookings/BookingFilters.tsx
Normal file
246
apps/frontend/src/components/bookings/BookingFilters.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Advanced Booking Filters Component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { BookingFilters as IBookingFilters, BookingStatus } from '@/types/booking';
|
||||||
|
|
||||||
|
interface BookingFiltersProps {
|
||||||
|
filters: IBookingFilters;
|
||||||
|
onFiltersChange: (filters: Partial<IBookingFilters>) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BookingFilters: React.FC<BookingFiltersProps> = ({
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
onReset,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const handleStatusChange = (status: BookingStatus) => {
|
||||||
|
const currentStatuses = filters.status || [];
|
||||||
|
const newStatuses = currentStatuses.includes(status)
|
||||||
|
? currentStatuses.filter((s) => s !== status)
|
||||||
|
: [...currentStatuses, status];
|
||||||
|
|
||||||
|
onFiltersChange({ status: newStatuses });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Filters</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
{isExpanded ? 'Show Less' : 'Show More'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Reset All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Always visible filters */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||||
|
{/* Search */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Booking number, shipper, consignee..."
|
||||||
|
value={filters.search || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ search: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carrier */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Carrier
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Carrier name or SCAC"
|
||||||
|
value={filters.carrier || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ carrier: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Origin Port */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Origin Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Port code"
|
||||||
|
value={filters.originPort || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ originPort: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filters */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Status
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.values(BookingStatus).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => handleStatusChange(status)}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||||
|
filters.status?.includes(status)
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ').toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded filters */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 pt-4 border-t border-gray-200">
|
||||||
|
{/* Destination Port */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Destination Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Port code"
|
||||||
|
value={filters.destinationPort || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ destinationPort: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shipper */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Shipper
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Shipper name"
|
||||||
|
value={filters.shipper || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ shipper: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consignee */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Consignee
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Consignee name"
|
||||||
|
value={filters.consignee || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ consignee: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created From */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Created From
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.createdFrom || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ createdFrom: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created To */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Created To
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.createdTo || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ createdTo: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ETD From */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
ETD From
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.etdFrom || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ etdFrom: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ETD To */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
ETD To
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.etdTo || ''}
|
||||||
|
onChange={(e) => onFiltersChange({ etdTo: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Sort By
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.sortBy || 'createdAt'}
|
||||||
|
onChange={(e) => onFiltersChange({ sortBy: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="createdAt">Created Date</option>
|
||||||
|
<option value="bookingNumber">Booking Number</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
<option value="etd">ETD</option>
|
||||||
|
<option value="eta">ETA</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active filters count */}
|
||||||
|
{Object.keys(filters).length > 0 && (
|
||||||
|
<div className="mt-4 text-sm text-gray-600">
|
||||||
|
{Object.keys(filters).filter((key) => {
|
||||||
|
const value = filters[key as keyof IBookingFilters];
|
||||||
|
return Array.isArray(value) ? value.length > 0 : Boolean(value);
|
||||||
|
}).length}{' '}
|
||||||
|
active filter(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
273
apps/frontend/src/components/bookings/BookingsTable.tsx
Normal file
273
apps/frontend/src/components/bookings/BookingsTable.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Advanced Bookings Table with TanStack Table and Virtual Scrolling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
flexRender,
|
||||||
|
ColumnDef,
|
||||||
|
SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { Booking, BookingStatus } from '@/types/booking';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface BookingsTableProps {
|
||||||
|
bookings: Booking[];
|
||||||
|
selectedBookings: Set<string>;
|
||||||
|
onToggleSelection: (bookingId: string) => void;
|
||||||
|
onToggleAll: () => void;
|
||||||
|
onRowClick?: (booking: Booking) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BookingsTable: React.FC<BookingsTableProps> = ({
|
||||||
|
bookings,
|
||||||
|
selectedBookings,
|
||||||
|
onToggleSelection,
|
||||||
|
onToggleAll,
|
||||||
|
onRowClick,
|
||||||
|
}) => {
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<Booking>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedBookings.size === bookings.length && bookings.length > 0}
|
||||||
|
onChange={onToggleAll}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedBookings.has(row.original.id)}
|
||||||
|
onChange={() => onToggleSelection(row.original.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'bookingNumber',
|
||||||
|
header: 'Booking #',
|
||||||
|
cell: (info) => (
|
||||||
|
<span className="font-medium text-blue-600">{info.getValue() as string}</span>
|
||||||
|
),
|
||||||
|
size: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: (info) => {
|
||||||
|
const status = info.getValue() as BookingStatus;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(
|
||||||
|
status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{status.replace('_', ' ').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'rateQuote.carrierName',
|
||||||
|
header: 'Carrier',
|
||||||
|
cell: (info) => info.getValue() as string,
|
||||||
|
size: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'rateQuote.origin',
|
||||||
|
header: 'Origin',
|
||||||
|
cell: (info) => info.getValue() as string,
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'rateQuote.destination',
|
||||||
|
header: 'Destination',
|
||||||
|
cell: (info) => info.getValue() as string,
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'shipper.name',
|
||||||
|
header: 'Shipper',
|
||||||
|
cell: (info) => info.getValue() as string,
|
||||||
|
size: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'consignee.name',
|
||||||
|
header: 'Consignee',
|
||||||
|
cell: (info) => info.getValue() as string,
|
||||||
|
size: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'rateQuote.etd',
|
||||||
|
header: 'ETD',
|
||||||
|
cell: (info) => {
|
||||||
|
const value = info.getValue();
|
||||||
|
return value ? format(new Date(value as string), 'MMM dd, yyyy') : '-';
|
||||||
|
},
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'rateQuote.eta',
|
||||||
|
header: 'ETA',
|
||||||
|
cell: (info) => {
|
||||||
|
const value = info.getValue();
|
||||||
|
return value ? format(new Date(value as string), 'MMM dd, yyyy') : '-';
|
||||||
|
},
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'containers',
|
||||||
|
header: 'Containers',
|
||||||
|
cell: (info) => {
|
||||||
|
const containers = info.getValue() as any[];
|
||||||
|
return <span>{containers.length}</span>;
|
||||||
|
},
|
||||||
|
size: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: 'Created',
|
||||||
|
cell: (info) => format(new Date(info.getValue() as string), 'MMM dd, yyyy'),
|
||||||
|
size: 120,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[bookings.length, selectedBookings, onToggleAll, onToggleSelection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: bookings,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rows } = table.getRowModel();
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => tableContainerRef.current,
|
||||||
|
estimateSize: () => 60,
|
||||||
|
overscan: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
|
const paddingTop = virtualRows.length > 0 ? virtualRows[0]?.start || 0 : 0;
|
||||||
|
const paddingBottom =
|
||||||
|
virtualRows.length > 0
|
||||||
|
? totalSize - (virtualRows[virtualRows.length - 1]?.end || 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={tableContainerRef}
|
||||||
|
className="overflow-auto"
|
||||||
|
style={{ height: '600px' }}
|
||||||
|
>
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||||
|
style={{ width: header.getSize() }}
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
{header.column.getIsSorted() && (
|
||||||
|
<span>
|
||||||
|
{header.column.getIsSorted() === 'asc' ? '↑' : '↓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{paddingTop > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td style={{ height: `${paddingTop}px` }} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
onClick={() => onRowClick?.(row.original)}
|
||||||
|
className={`hover:bg-gray-50 cursor-pointer ${
|
||||||
|
selectedBookings.has(row.original.id) ? 'bg-blue-50' : ''
|
||||||
|
}`}
|
||||||
|
style={{ height: `${virtualRow.size}px` }}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
|
||||||
|
style={{ width: cell.column.getSize() }}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{paddingBottom > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td style={{ height: `${paddingBottom}px` }} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusColor(status: BookingStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case BookingStatus.DRAFT:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
case BookingStatus.CONFIRMED:
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case BookingStatus.IN_PROGRESS:
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case BookingStatus.COMPLETED:
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case BookingStatus.CANCELLED:
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
102
apps/frontend/src/components/bookings/BulkActions.tsx
Normal file
102
apps/frontend/src/components/bookings/BulkActions.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Bulk Actions Component for Bookings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ExportFormat, ExportOptions } from '@/types/booking';
|
||||||
|
|
||||||
|
interface BulkActionsProps {
|
||||||
|
selectedCount: number;
|
||||||
|
onExport: (options: ExportOptions) => Promise<void>;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BulkActions: React.FC<BulkActionsProps> = ({
|
||||||
|
selectedCount,
|
||||||
|
onExport,
|
||||||
|
onClearSelection,
|
||||||
|
}) => {
|
||||||
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async (format: ExportFormat) => {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await onExport({ format });
|
||||||
|
setShowExportMenu(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`Export failed: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm font-medium text-blue-900">
|
||||||
|
{selectedCount} booking{selectedCount !== 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClearSelection}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 underline"
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Export dropdown */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExportMenu(!showExportMenu)}
|
||||||
|
disabled={exporting}
|
||||||
|
className="px-4 py-2 bg-white border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{exporting ? 'Exporting...' : 'Export Selected'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showExportMenu && !exporting && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-20">
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport(ExportFormat.CSV)}
|
||||||
|
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Export as CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport(ExportFormat.EXCEL)}
|
||||||
|
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Export as Excel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport(ExportFormat.JSON)}
|
||||||
|
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Export as JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk update button */}
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement bulk update modal
|
||||||
|
alert('Bulk update functionality coming soon!');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bulk Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
apps/frontend/src/components/bookings/index.ts
Normal file
7
apps/frontend/src/components/bookings/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Bookings Components Barrel Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { BookingFilters } from './BookingFilters';
|
||||||
|
export { BookingsTable } from './BookingsTable';
|
||||||
|
export { BulkActions } from './BulkActions';
|
||||||
152
apps/frontend/src/hooks/useBookings.ts
Normal file
152
apps/frontend/src/hooks/useBookings.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* Custom hook for managing bookings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Booking, BookingFilters, BookingListResponse, ExportOptions } from '@/types/booking';
|
||||||
|
|
||||||
|
export function useBookings(initialFilters?: BookingFilters) {
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [filters, setFilters] = useState<BookingFilters>(initialFilters || {});
|
||||||
|
const [selectedBookings, setSelectedBookings] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const fetchBookings = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build query parameters
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.status && filters.status.length > 0) {
|
||||||
|
queryParams.append('status', filters.status.join(','));
|
||||||
|
}
|
||||||
|
if (filters.search) queryParams.append('search', filters.search);
|
||||||
|
if (filters.carrier) queryParams.append('carrier', filters.carrier);
|
||||||
|
if (filters.originPort) queryParams.append('originPort', filters.originPort);
|
||||||
|
if (filters.destinationPort) queryParams.append('destinationPort', filters.destinationPort);
|
||||||
|
if (filters.shipper) queryParams.append('shipper', filters.shipper);
|
||||||
|
if (filters.consignee) queryParams.append('consignee', filters.consignee);
|
||||||
|
if (filters.createdFrom) queryParams.append('createdFrom', filters.createdFrom);
|
||||||
|
if (filters.createdTo) queryParams.append('createdTo', filters.createdTo);
|
||||||
|
if (filters.etdFrom) queryParams.append('etdFrom', filters.etdFrom);
|
||||||
|
if (filters.etdTo) queryParams.append('etdTo', filters.etdTo);
|
||||||
|
if (filters.sortBy) queryParams.append('sortBy', filters.sortBy);
|
||||||
|
if (filters.sortOrder) queryParams.append('sortOrder', filters.sortOrder);
|
||||||
|
|
||||||
|
queryParams.append('page', String(filters.page || 1));
|
||||||
|
queryParams.append('pageSize', String(filters.pageSize || 20));
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/bookings/advanced/search?${queryParams.toString()}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch bookings');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: BookingListResponse = await response.json();
|
||||||
|
setBookings(data.bookings);
|
||||||
|
setTotal(data.total);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBookings();
|
||||||
|
}, [fetchBookings]);
|
||||||
|
|
||||||
|
const updateFilters = useCallback((newFilters: Partial<BookingFilters>) => {
|
||||||
|
setFilters((prev) => ({ ...prev, ...newFilters }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
setFilters({});
|
||||||
|
setSelectedBookings(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleBookingSelection = useCallback((bookingId: string) => {
|
||||||
|
setSelectedBookings((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(bookingId)) {
|
||||||
|
newSet.delete(bookingId);
|
||||||
|
} else {
|
||||||
|
newSet.add(bookingId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleAllBookings = useCallback(() => {
|
||||||
|
if (selectedBookings.size === bookings.length) {
|
||||||
|
setSelectedBookings(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedBookings(new Set(bookings.map((b) => b.id)));
|
||||||
|
}
|
||||||
|
}, [bookings, selectedBookings]);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelectedBookings(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exportBookings = useCallback(async (options: ExportOptions) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/bookings/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
format: options.format,
|
||||||
|
fields: options.fields,
|
||||||
|
bookingIds: options.bookingIds || Array.from(selectedBookings),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Export failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download file
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `bookings-export.${options.format}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(err.message || 'Export failed');
|
||||||
|
}
|
||||||
|
}, [selectedBookings]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookings,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
filters,
|
||||||
|
selectedBookings,
|
||||||
|
updateFilters,
|
||||||
|
resetFilters,
|
||||||
|
toggleBookingSelection,
|
||||||
|
toggleAllBookings,
|
||||||
|
clearSelection,
|
||||||
|
exportBookings,
|
||||||
|
refetch: fetchBookings,
|
||||||
|
};
|
||||||
|
}
|
||||||
136
apps/frontend/src/pages/BookingsManagement.tsx
Normal file
136
apps/frontend/src/pages/BookingsManagement.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Advanced Bookings Management Page
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useBookings } from '@/hooks/useBookings';
|
||||||
|
import { BookingFilters } from '@/components/bookings/BookingFilters';
|
||||||
|
import { BookingsTable } from '@/components/bookings/BookingsTable';
|
||||||
|
import { BulkActions } from '@/components/bookings/BulkActions';
|
||||||
|
|
||||||
|
export const BookingsManagement: React.FC = () => {
|
||||||
|
const {
|
||||||
|
bookings,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
filters,
|
||||||
|
selectedBookings,
|
||||||
|
updateFilters,
|
||||||
|
resetFilters,
|
||||||
|
toggleBookingSelection,
|
||||||
|
toggleAllBookings,
|
||||||
|
clearSelection,
|
||||||
|
exportBookings,
|
||||||
|
} = useBookings({ page: 1, pageSize: 50 });
|
||||||
|
|
||||||
|
const handleRowClick = (booking: any) => {
|
||||||
|
// Navigate to booking details
|
||||||
|
window.location.href = `/bookings/${booking.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Bookings Management</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Manage and filter your bookings with advanced search and bulk actions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<BookingFilters
|
||||||
|
filters={filters}
|
||||||
|
onFiltersChange={updateFilters}
|
||||||
|
onReset={resetFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bulk Actions */}
|
||||||
|
<BulkActions
|
||||||
|
selectedCount={selectedBookings.size}
|
||||||
|
onExport={exportBookings}
|
||||||
|
onClearSelection={clearSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Results Summary */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Showing {bookings.length} of {total} bookings
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-blue-600">Loading...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!loading && bookings.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-12 w-12 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No bookings found</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Try adjusting your filters or create a new booking
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<BookingsTable
|
||||||
|
bookings={bookings}
|
||||||
|
selectedBookings={selectedBookings}
|
||||||
|
onToggleSelection={toggleBookingSelection}
|
||||||
|
onToggleAll={toggleAllBookings}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > (filters.pageSize || 50) && (
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
updateFilters({ page: (filters.page || 1) - 1 })
|
||||||
|
}
|
||||||
|
disabled={!filters.page || filters.page <= 1}
|
||||||
|
className="px-4 py-2 bg-white border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Page {filters.page || 1} of {Math.ceil(total / (filters.pageSize || 50))}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
updateFilters({ page: (filters.page || 1) + 1 })
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
(filters.page || 1) >= Math.ceil(total / (filters.pageSize || 50))
|
||||||
|
}
|
||||||
|
className="px-4 py-2 bg-white border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
268
apps/frontend/src/pages/CarrierManagement.tsx
Normal file
268
apps/frontend/src/pages/CarrierManagement.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Carrier Management Page
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Carrier, CarrierStatus, CreateCarrierInput, UpdateCarrierInput } from '@/types/carrier';
|
||||||
|
import { CarrierForm } from '@/components/admin/CarrierForm';
|
||||||
|
|
||||||
|
export const CarrierManagement: React.FC = () => {
|
||||||
|
const [carriers, setCarriers] = useState<Carrier[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingCarrier, setEditingCarrier] = useState<Carrier | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCarriers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCarriers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/carriers', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch carriers');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setCarriers(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (data: CreateCarrierInput) => {
|
||||||
|
const response = await fetch('/api/v1/carriers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to create carrier');
|
||||||
|
|
||||||
|
await fetchCarriers();
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (data: UpdateCarrierInput) => {
|
||||||
|
if (!editingCarrier) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/carriers/${editingCarrier.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to update carrier');
|
||||||
|
|
||||||
|
await fetchCarriers();
|
||||||
|
setEditingCarrier(null);
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (carrierId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this carrier?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/carriers/${carrierId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to delete carrier');
|
||||||
|
|
||||||
|
await fetchCarriers();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (carrier: Carrier) => {
|
||||||
|
const newStatus =
|
||||||
|
carrier.status === CarrierStatus.ACTIVE
|
||||||
|
? CarrierStatus.INACTIVE
|
||||||
|
: CarrierStatus.ACTIVE;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleUpdate({ status: newStatus });
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
|
Carrier Management
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Manage carrier integrations and configurations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCarrier(null);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add Carrier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Modal */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-6">
|
||||||
|
{editingCarrier ? 'Edit Carrier' : 'Add New Carrier'}
|
||||||
|
</h2>
|
||||||
|
<CarrierForm
|
||||||
|
carrier={editingCarrier || undefined}
|
||||||
|
onSubmit={editingCarrier ? handleUpdate : handleCreate}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingCarrier(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Carriers Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-600">Loading carriers...</div>
|
||||||
|
</div>
|
||||||
|
) : carriers.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-12 w-12 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900">
|
||||||
|
No carriers configured
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Get started by adding your first carrier integration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{carriers.map((carrier) => (
|
||||||
|
<div
|
||||||
|
key={carrier.id}
|
||||||
|
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{carrier.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">SCAC: {carrier.scac}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
|
carrier.status === CarrierStatus.ACTIVE
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: carrier.status === CarrierStatus.MAINTENANCE
|
||||||
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{carrier.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="space-y-2 mb-4 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Priority:</span>
|
||||||
|
<span className="font-medium">{carrier.priority}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Rate Limit:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{carrier.rateLimit || 'N/A'} req/min
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Timeout:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{carrier.timeout || 'N/A'} ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingCarrier(carrier);
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(carrier)}
|
||||||
|
className="flex-1 px-3 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{carrier.status === CarrierStatus.ACTIVE
|
||||||
|
? 'Deactivate'
|
||||||
|
: 'Activate'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(carrier.id)}
|
||||||
|
className="px-3 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
332
apps/frontend/src/pages/CarrierMonitoring.tsx
Normal file
332
apps/frontend/src/pages/CarrierMonitoring.tsx
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* Carrier Monitoring Dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { CarrierStats, CarrierHealthCheck } from '@/types/carrier';
|
||||||
|
|
||||||
|
export const CarrierMonitoring: React.FC = () => {
|
||||||
|
const [stats, setStats] = useState<CarrierStats[]>([]);
|
||||||
|
const [health, setHealth] = useState<CarrierHealthCheck[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [timeRange, setTimeRange] = useState('24h');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMonitoringData();
|
||||||
|
// Refresh every 30 seconds
|
||||||
|
const interval = setInterval(fetchMonitoringData, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
|
const fetchMonitoringData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statsRes, healthRes] = await Promise.all([
|
||||||
|
fetch(`/api/v1/carriers/stats?timeRange=${timeRange}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fetch('/api/v1/carriers/health', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('accessToken')}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!statsRes.ok || !healthRes.ok) {
|
||||||
|
throw new Error('Failed to fetch monitoring data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [statsData, healthData] = await Promise.all([
|
||||||
|
statsRes.json(),
|
||||||
|
healthRes.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStats(statsData);
|
||||||
|
setHealth(healthData);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHealthStatus = (carrierId: string): CarrierHealthCheck | undefined => {
|
||||||
|
return health.find((h) => h.carrierId === carrierId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHealthColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'healthy':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'degraded':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'down':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate overall stats
|
||||||
|
const totalRequests = stats.reduce((sum, s) => sum + s.totalRequests, 0);
|
||||||
|
const totalSuccessful = stats.reduce((sum, s) => sum + s.successfulRequests, 0);
|
||||||
|
const totalFailed = stats.reduce((sum, s) => sum + s.failedRequests, 0);
|
||||||
|
const overallSuccessRate = totalRequests > 0 ? (totalSuccessful / totalRequests) * 100 : 0;
|
||||||
|
const avgResponseTime = stats.length > 0
|
||||||
|
? stats.reduce((sum, s) => sum + s.averageResponseTime, 0) / stats.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
|
Carrier Monitoring
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Real-time monitoring of carrier API performance and health
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={timeRange}
|
||||||
|
onChange={(e) => setTimeRange(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="1h">Last Hour</option>
|
||||||
|
<option value="24h">Last 24 Hours</option>
|
||||||
|
<option value="7d">Last 7 Days</option>
|
||||||
|
<option value="30d">Last 30 Days</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={fetchMonitoringData}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overall Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="text-sm font-medium text-gray-500 mb-1">
|
||||||
|
Total Requests
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-900">
|
||||||
|
{totalRequests.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="text-sm font-medium text-gray-500 mb-1">
|
||||||
|
Success Rate
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-green-600">
|
||||||
|
{overallSuccessRate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="text-sm font-medium text-gray-500 mb-1">
|
||||||
|
Failed Requests
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-red-600">
|
||||||
|
{totalFailed.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="text-sm font-medium text-gray-500 mb-1">
|
||||||
|
Avg Response Time
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-blue-600">
|
||||||
|
{avgResponseTime.toFixed(0)}ms
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carrier Stats Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
Carrier Performance
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<p className="text-gray-500">No monitoring data available</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Carrier
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Health
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Requests
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Success Rate
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Error Rate
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Avg Response
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Availability
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||||
|
Last Request
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const healthStatus = getHealthStatus(stat.carrierId);
|
||||||
|
const successRate = (stat.successfulRequests / stat.totalRequests) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={stat.carrierId} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{stat.carrierName}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{healthStatus && (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${getHealthColor(
|
||||||
|
healthStatus.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{healthStatus.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-900">
|
||||||
|
{stat.totalRequests.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
successRate >= 95
|
||||||
|
? 'text-green-600'
|
||||||
|
: successRate >= 80
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{successRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
stat.errorRate < 5
|
||||||
|
? 'text-green-600'
|
||||||
|
: stat.errorRate < 15
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stat.errorRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-900">
|
||||||
|
{stat.averageResponseTime.toFixed(0)}ms
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
stat.availability >= 99
|
||||||
|
? 'text-green-600'
|
||||||
|
: stat.availability >= 95
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stat.availability.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{stat.lastRequestAt
|
||||||
|
? new Date(stat.lastRequestAt).toLocaleString()
|
||||||
|
: 'Never'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Health Alerts */}
|
||||||
|
{health.some((h) => h.errors.length > 0) && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 bg-red-50">
|
||||||
|
<h2 className="text-lg font-semibold text-red-900">
|
||||||
|
Active Alerts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
{health
|
||||||
|
.filter((h) => h.errors.length > 0)
|
||||||
|
.map((healthCheck) => (
|
||||||
|
<div key={healthCheck.carrierId} className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{stats.find((s) => s.carrierId === healthCheck.carrierId)
|
||||||
|
?.carrierName || 'Unknown Carrier'}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-full text-xs font-medium ${getHealthColor(
|
||||||
|
healthCheck.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{healthCheck.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{healthCheck.errors.map((error, index) => (
|
||||||
|
<li key={index} className="text-sm text-red-600">
|
||||||
|
• {error}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
100
apps/frontend/src/types/booking.ts
Normal file
100
apps/frontend/src/types/booking.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Booking Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum BookingStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
CONFIRMED = 'confirmed',
|
||||||
|
IN_PROGRESS = 'in_progress',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ContainerType {
|
||||||
|
DRY_20 = '20ft',
|
||||||
|
DRY_40 = '40ft',
|
||||||
|
HIGH_CUBE_40 = '40ft HC',
|
||||||
|
REEFER_20 = '20ft Reefer',
|
||||||
|
REEFER_40 = '40ft Reefer',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Address {
|
||||||
|
name: string;
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
state?: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Container {
|
||||||
|
id: string;
|
||||||
|
type: ContainerType;
|
||||||
|
containerNumber?: string;
|
||||||
|
sealNumber?: string;
|
||||||
|
vgm?: number;
|
||||||
|
temperature?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateQuote {
|
||||||
|
id: string;
|
||||||
|
carrierName: string;
|
||||||
|
carrierScac: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
priceValue: number;
|
||||||
|
priceCurrency: string;
|
||||||
|
etd?: string;
|
||||||
|
eta?: string;
|
||||||
|
transitDays?: number;
|
||||||
|
validUntil?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Booking {
|
||||||
|
id: string;
|
||||||
|
bookingNumber: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
shipper: Address;
|
||||||
|
consignee: Address;
|
||||||
|
containers: Container[];
|
||||||
|
rateQuote: RateQuote;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingFilters {
|
||||||
|
status?: BookingStatus[];
|
||||||
|
search?: string;
|
||||||
|
carrier?: string;
|
||||||
|
originPort?: string;
|
||||||
|
destinationPort?: string;
|
||||||
|
shipper?: string;
|
||||||
|
consignee?: string;
|
||||||
|
createdFrom?: string;
|
||||||
|
createdTo?: string;
|
||||||
|
etdFrom?: string;
|
||||||
|
etdTo?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingListResponse {
|
||||||
|
bookings: Booking[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ExportFormat {
|
||||||
|
CSV = 'csv',
|
||||||
|
EXCEL = 'excel',
|
||||||
|
JSON = 'json',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportOptions {
|
||||||
|
format: ExportFormat;
|
||||||
|
fields?: string[];
|
||||||
|
bookingIds?: string[];
|
||||||
|
}
|
||||||
64
apps/frontend/src/types/carrier.ts
Normal file
64
apps/frontend/src/types/carrier.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Carrier Types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export enum CarrierStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
MAINTENANCE = 'maintenance',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Carrier {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scac: string;
|
||||||
|
status: CarrierStatus;
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
priority: number;
|
||||||
|
rateLimit?: number;
|
||||||
|
timeout?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarrierStats {
|
||||||
|
carrierId: string;
|
||||||
|
carrierName: string;
|
||||||
|
totalRequests: number;
|
||||||
|
successfulRequests: number;
|
||||||
|
failedRequests: number;
|
||||||
|
averageResponseTime: number;
|
||||||
|
lastRequestAt?: string;
|
||||||
|
errorRate: number;
|
||||||
|
availability: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarrierHealthCheck {
|
||||||
|
carrierId: string;
|
||||||
|
status: 'healthy' | 'degraded' | 'down';
|
||||||
|
responseTime: number;
|
||||||
|
lastCheck: string;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCarrierInput {
|
||||||
|
name: string;
|
||||||
|
scac: string;
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
priority?: number;
|
||||||
|
rateLimit?: number;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCarrierInput {
|
||||||
|
name?: string;
|
||||||
|
scac?: string;
|
||||||
|
status?: CarrierStatus;
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
priority?: number;
|
||||||
|
rateLimit?: number;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
158
apps/frontend/src/utils/export.ts
Normal file
158
apps/frontend/src/utils/export.ts
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Client-side export utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
import { Booking } from '@/types/booking';
|
||||||
|
|
||||||
|
export interface ExportField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
formatter?: (value: any) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BOOKING_FIELDS: ExportField[] = [
|
||||||
|
{ key: 'bookingNumber', label: 'Booking Number' },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'rateQuote.carrierName', label: 'Carrier' },
|
||||||
|
{ key: 'rateQuote.origin', label: 'Origin' },
|
||||||
|
{ key: 'rateQuote.destination', label: 'Destination' },
|
||||||
|
{ key: 'shipper.name', label: 'Shipper' },
|
||||||
|
{ key: 'consignee.name', label: 'Consignee' },
|
||||||
|
{
|
||||||
|
key: 'rateQuote.etd',
|
||||||
|
label: 'ETD',
|
||||||
|
formatter: (value) => (value ? new Date(value).toLocaleDateString() : ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rateQuote.eta',
|
||||||
|
label: 'ETA',
|
||||||
|
formatter: (value) => (value ? new Date(value).toLocaleDateString() : ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'containers',
|
||||||
|
label: 'Containers',
|
||||||
|
formatter: (value) => (Array.isArray(value) ? value.length.toString() : '0'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
label: 'Created',
|
||||||
|
formatter: (value) => new Date(value).toLocaleDateString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nested object value by key path
|
||||||
|
*/
|
||||||
|
function getNestedValue(obj: any, path: string): any {
|
||||||
|
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export bookings to CSV
|
||||||
|
*/
|
||||||
|
export function exportToCSV(
|
||||||
|
data: Booking[],
|
||||||
|
fields: ExportField[] = DEFAULT_BOOKING_FIELDS,
|
||||||
|
filename: string = 'bookings-export.csv'
|
||||||
|
): void {
|
||||||
|
// Create CSV header
|
||||||
|
const header = fields.map((f) => f.label).join(',');
|
||||||
|
|
||||||
|
// Create CSV rows
|
||||||
|
const rows = data.map((booking) => {
|
||||||
|
return fields
|
||||||
|
.map((field) => {
|
||||||
|
const value = getNestedValue(booking, field.key);
|
||||||
|
const formatted = field.formatter ? field.formatter(value) : value;
|
||||||
|
// Escape quotes and wrap in quotes if contains comma
|
||||||
|
const escaped = String(formatted || '')
|
||||||
|
.replace(/"/g, '""');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
})
|
||||||
|
.join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine header and rows
|
||||||
|
const csv = [header, ...rows].join('\n');
|
||||||
|
|
||||||
|
// Create blob and download
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
saveAs(blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export bookings to Excel
|
||||||
|
*/
|
||||||
|
export function exportToExcel(
|
||||||
|
data: Booking[],
|
||||||
|
fields: ExportField[] = DEFAULT_BOOKING_FIELDS,
|
||||||
|
filename: string = 'bookings-export.xlsx'
|
||||||
|
): void {
|
||||||
|
// Create worksheet data
|
||||||
|
const wsData = [
|
||||||
|
// Header row
|
||||||
|
fields.map((f) => f.label),
|
||||||
|
// Data rows
|
||||||
|
...data.map((booking) =>
|
||||||
|
fields.map((field) => {
|
||||||
|
const value = getNestedValue(booking, field.key);
|
||||||
|
return field.formatter ? field.formatter(value) : value || '';
|
||||||
|
})
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create worksheet
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||||
|
|
||||||
|
// Set column widths
|
||||||
|
ws['!cols'] = fields.map(() => ({ wch: 20 }));
|
||||||
|
|
||||||
|
// Create workbook
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'Bookings');
|
||||||
|
|
||||||
|
// Generate Excel file
|
||||||
|
const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||||
|
const blob = new Blob([excelBuffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
});
|
||||||
|
saveAs(blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export bookings to JSON
|
||||||
|
*/
|
||||||
|
export function exportToJSON(
|
||||||
|
data: Booking[],
|
||||||
|
filename: string = 'bookings-export.json'
|
||||||
|
): void {
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json;charset=utf-8;' });
|
||||||
|
saveAs(blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export bookings based on format
|
||||||
|
*/
|
||||||
|
export function exportBookings(
|
||||||
|
data: Booking[],
|
||||||
|
format: 'csv' | 'excel' | 'json',
|
||||||
|
fields?: ExportField[],
|
||||||
|
filename?: string
|
||||||
|
): void {
|
||||||
|
switch (format) {
|
||||||
|
case 'csv':
|
||||||
|
exportToCSV(data, fields, filename);
|
||||||
|
break;
|
||||||
|
case 'excel':
|
||||||
|
exportToExcel(data, fields, filename);
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
exportToJSON(data, filename);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported export format: ${format}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user