feature phase 3

This commit is contained in:
David-Henri ARNAUD 2025-10-13 17:54:32 +02:00
parent 07258e5adb
commit c5c15eb1f9
48 changed files with 7755 additions and 4 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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": {

View File

@ -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: [

View 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 {}

View File

@ -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,

View 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(),
};
}
}

View File

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

View File

@ -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(),
};
}
}

View 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(),
};
}
}

View 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[];
}

View 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;
}

View File

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

View 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(),
};
}
}

View File

@ -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 {}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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}`,
});
}
}

View 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));
}
}

View 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 {}

View 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,
};
}
}

View 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 };
}
}

View 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 };
}
}

View File

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

View File

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

View File

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

View File

@ -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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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",

View 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>
);
};

View File

@ -0,0 +1,5 @@
/**
* Admin Components Barrel Export
*/
export { CarrierForm } from './CarrierForm';

View 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>
);
};

View 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';
}
}

View 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>
);
};

View File

@ -0,0 +1,7 @@
/**
* Bookings Components Barrel Export
*/
export { BookingFilters } from './BookingFilters';
export { BookingsTable } from './BookingsTable';
export { BulkActions } from './BulkActions';

View 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,
};
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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[];
}

View 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;
}

View 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}`);
}
}