import { Controller, Post, Get, Delete, Param, Body, UseGuards, UseInterceptors, UploadedFile, HttpCode, HttpStatus, Logger, BadRequestException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiConsumes, ApiBody, } from '@nestjs/swagger'; import { diskStorage } from 'multer'; import { extname } from 'path'; 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 { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter'; import { CsvConverterService } from '@infrastructure/carriers/csv-loader/csv-converter.service'; import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; import { CsvRateUploadDto, CsvRateUploadResponseDto, CsvRateConfigDto, CsvFileValidationDto, } from '../../dto/csv-rate-upload.dto'; import { CsvRateMapper } from '../../mappers/csv-rate.mapper'; import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter'; import { ConfigService } from '@nestjs/config'; import * as fs from 'fs'; import * as path from 'path'; /** * CSV Rates Admin Controller * * ADMIN-ONLY endpoints for managing CSV rate files * Protected by JWT + Roles guard */ @ApiTags('Admin - CSV Rates') @Controller('admin/csv-rates') @ApiBearerAuth() @UseGuards(JwtAuthGuard, RolesGuard) @Roles('ADMIN') // ⚠️ ONLY ADMIN can access these endpoints export class CsvRatesAdminController { private readonly logger = new Logger(CsvRatesAdminController.name); constructor( private readonly csvLoader: CsvRateLoaderAdapter, private readonly csvConverter: CsvConverterService, private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository, private readonly csvRateMapper: CsvRateMapper, private readonly s3Storage: S3StorageAdapter, private readonly configService: ConfigService ) {} /** * Upload CSV rate file (ADMIN only) */ @Post('upload') @HttpCode(HttpStatus.CREATED) @UseInterceptors( FileInterceptor('file', { storage: diskStorage({ destination: './apps/backend/src/infrastructure/storage/csv-storage/rates', filename: (req, file, cb) => { // Use timestamp + random string to avoid conflicts // We'll rename it later once we have the company name from req.body const timestamp = Date.now(); const randomStr = Math.random().toString(36).substring(7); const tempFilename = `temp-${timestamp}-${randomStr}.csv`; cb(null, tempFilename); }, }), fileFilter: (req, file, cb) => { // Only allow CSV files if (extname(file.originalname).toLowerCase() !== '.csv') { return cb(new BadRequestException('Only CSV files are allowed'), false); } cb(null, true); }, limits: { fileSize: 10 * 1024 * 1024, // 10MB max }, }) ) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload CSV rate file (ADMIN only)', description: 'Upload a CSV file containing shipping rates for a carrier company. File must be valid CSV format with required columns. Maximum file size: 10MB.', }) @ApiBody({ schema: { type: 'object', required: ['companyName', 'companyEmail', 'file'], properties: { companyName: { type: 'string', description: 'Carrier company name', example: 'SSC Consolidation', }, companyEmail: { type: 'string', format: 'email', description: 'Email address for booking requests', example: 'bookings@sscconsolidation.com', }, file: { type: 'string', format: 'binary', description: 'CSV file to upload', }, }, }, }) @ApiResponse({ status: HttpStatus.CREATED, description: 'CSV file uploaded and validated successfully', type: CsvRateUploadResponseDto, }) @ApiResponse({ status: 400, description: 'Invalid file format or validation failed', }) @ApiResponse({ status: 403, description: 'Forbidden - Admin role required', }) async uploadCsv( @UploadedFile() file: Express.Multer.File, @Body() dto: CsvRateUploadDto, @CurrentUser() user: UserPayload ): Promise { this.logger.log(`[Admin: ${user.email}] Uploading CSV for company: ${dto.companyName}`); if (!file) { throw new BadRequestException('File is required'); } try { // Generate final filename based on company name const sanitizedCompanyName = dto.companyName .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, ''); const finalFilename = `${sanitizedCompanyName}.csv`; // Auto-convert CSV if needed (FOB FRET → Standard format) const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName); let filePathToValidate = conversionResult.convertedPath; if (conversionResult.wasConverted) { this.logger.log( `Converted ${conversionResult.rowsConverted} rows from FOB FRET format to standard format` ); } // Validate CSV file structure using the converted path const validation = await this.csvLoader.validateCsvFile(filePathToValidate); if (!validation.valid) { this.logger.error( `CSV validation failed for ${dto.companyName}: ${validation.errors.join(', ')}` ); throw new BadRequestException({ message: 'CSV validation failed', errors: validation.errors, }); } // Load rates to verify parsing using the converted path // Pass company name from form to override CSV column value const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate, dto.companyEmail, dto.companyName); const ratesCount = rates.length; this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`); // Rename file to final name (company-name.csv) const finalPath = path.join(path.dirname(filePathToValidate), finalFilename); // Delete old file if exists if (fs.existsSync(finalPath)) { fs.unlinkSync(finalPath); this.logger.log(`Deleted old file: ${finalFilename}`); } // Rename temp file to final name fs.renameSync(filePathToValidate, finalPath); this.logger.log(`Renamed ${file.filename} to ${finalFilename}`); // Upload CSV file to MinIO/S3 let minioObjectKey: string | null = null; try { const csvBuffer = fs.readFileSync(finalPath); const bucket = this.configService.get('AWS_S3_BUCKET', 'xpeditis-csv-rates'); const objectKey = `csv-rates/${finalFilename}`; await this.s3Storage.upload({ bucket, key: objectKey, body: csvBuffer, contentType: 'text/csv', metadata: { companyName: dto.companyName, companyEmail: dto.companyEmail, uploadedBy: user.email, uploadedAt: new Date().toISOString(), }, }); minioObjectKey = objectKey; this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`); } catch (error: any) { this.logger.error(`⚠️ Failed to upload CSV to MinIO (will continue with local storage): ${error.message}`); // Don't fail the entire operation if MinIO upload fails // The file is still available locally } // Check if config exists for this company const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName); if (existingConfig) { // Update existing configuration await this.csvConfigRepository.update(existingConfig.id, { csvFilePath: finalFilename, uploadedAt: new Date(), uploadedBy: user.id, rowCount: ratesCount, lastValidatedAt: new Date(), metadata: { ...existingConfig.metadata, companyEmail: dto.companyEmail, // Store email in metadata minioObjectKey, // Store MinIO object key lastUpload: { timestamp: new Date().toISOString(), by: user.email, ratesCount, }, }, }); this.logger.log(`Updated CSV config for company: ${dto.companyName}`); } else { // Create new configuration await this.csvConfigRepository.create({ companyName: dto.companyName, csvFilePath: finalFilename, type: 'CSV_ONLY', hasApi: false, apiConnector: null, isActive: true, uploadedAt: new Date(), uploadedBy: user.id, rowCount: ratesCount, lastValidatedAt: new Date(), metadata: { uploadedBy: user.email, description: `${dto.companyName} shipping rates`, companyEmail: dto.companyEmail, // Store email in metadata minioObjectKey, // Store MinIO object key }, }); this.logger.log(`Created new CSV config for company: ${dto.companyName}`); } return { success: true, ratesCount, csvFilePath: finalFilename, companyName: dto.companyName, uploadedAt: new Date(), }; } catch (error: any) { this.logger.error(`CSV upload failed: ${error?.message || 'Unknown error'}`, error?.stack); throw error; } } /** * Get all CSV rate configurations */ @Get('config') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get all CSV rate configurations (ADMIN only)', description: 'Returns list of all CSV rate configurations with upload details.', }) @ApiResponse({ status: HttpStatus.OK, description: 'List of CSV rate configurations', type: [CsvRateConfigDto], }) async getAllConfigs(): Promise { this.logger.log('Fetching all CSV rate configs (admin)'); const configs = await this.csvConfigRepository.findAll(); return this.csvRateMapper.mapConfigEntitiesToDtos(configs); } /** * Get configuration for specific company */ @Get('config/:companyName') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Get CSV configuration for specific company (ADMIN only)', description: 'Returns CSV rate configuration details for a specific carrier.', }) @ApiResponse({ status: HttpStatus.OK, description: 'CSV rate configuration', type: CsvRateConfigDto, }) @ApiResponse({ status: 404, description: 'Company configuration not found', }) async getConfigByCompany(@Param('companyName') companyName: string): Promise { this.logger.log(`Fetching CSV config for company: ${companyName}`); const config = await this.csvConfigRepository.findByCompanyName(companyName); if (!config) { throw new BadRequestException(`No CSV configuration found for company: ${companyName}`); } return this.csvRateMapper.mapConfigEntityToDto(config); } /** * Validate CSV file */ @Post('validate/:companyName') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Validate CSV file for company (ADMIN only)', description: 'Validates the CSV file structure and data for a specific company without uploading.', }) @ApiResponse({ status: HttpStatus.OK, description: 'Validation result', type: CsvFileValidationDto, }) async validateCsvFile(@Param('companyName') companyName: string): Promise { this.logger.log(`Validating CSV file for company: ${companyName}`); const config = await this.csvConfigRepository.findByCompanyName(companyName); if (!config) { throw new BadRequestException(`No CSV configuration found for company: ${companyName}`); } const result = await this.csvLoader.validateCsvFile(config.csvFilePath); // Update validation timestamp if (result.valid && result.rowCount) { await this.csvConfigRepository.updateValidationInfo(companyName, result.rowCount, result); } return result; } /** * Delete CSV rate configuration */ @Delete('config/:companyName') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete CSV rate configuration (ADMIN only)', description: 'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.', }) @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'Configuration deleted successfully', }) @ApiResponse({ status: 404, description: 'Company configuration not found', }) async deleteConfig( @Param('companyName') companyName: string, @CurrentUser() user: UserPayload ): Promise { this.logger.warn(`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`); await this.csvConfigRepository.delete(companyName); this.logger.log(`Deleted CSV config for company: ${companyName}`); } /** * List all CSV files (Frontend compatibility endpoint) * Maps to GET /files for compatibility with frontend API client */ @Get('files') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'List all CSV files (ADMIN only)', description: 'Returns list of all uploaded CSV files with metadata. Alias for /config endpoint.', }) @ApiResponse({ status: HttpStatus.OK, description: 'List of CSV files', schema: { type: 'object', properties: { files: { type: 'array', items: { type: 'object', properties: { filename: { type: 'string', example: 'ssc-consolidation.csv' }, size: { type: 'number', example: 2048 }, uploadedAt: { type: 'string', format: 'date-time' }, rowCount: { type: 'number', example: 150 }, }, }, }, }, }, }) async listFiles(): Promise<{ files: any[] }> { this.logger.log('Fetching all CSV files (frontend compatibility)'); const configs = await this.csvConfigRepository.findAll(); // Map configs to file info format expected by frontend const files = configs.map((config) => { const filePath = path.join( process.cwd(), 'apps/backend/src/infrastructure/storage/csv-storage/rates', config.csvFilePath ); let fileSize = 0; try { const stats = fs.statSync(filePath); fileSize = stats.size; } catch (error) { this.logger.warn(`Could not get file size for ${config.csvFilePath}`); } return { filename: config.csvFilePath, size: fileSize, uploadedAt: config.uploadedAt.toISOString(), rowCount: config.rowCount, }; }); return { files }; } /** * Delete CSV file (Frontend compatibility endpoint) * Maps to DELETE /files/:filename */ @Delete('files/:filename') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Delete CSV file by filename (ADMIN only)', description: 'Deletes a CSV file and its configuration from the system.', }) @ApiResponse({ status: HttpStatus.OK, description: 'File deleted successfully', schema: { type: 'object', properties: { success: { type: 'boolean', example: true }, message: { type: 'string', example: 'File deleted successfully' }, }, }, }) @ApiResponse({ status: 404, description: 'File not found', }) async deleteFile( @Param('filename') filename: string, @CurrentUser() user: UserPayload ): Promise<{ success: boolean; message: string }> { this.logger.warn(`[Admin: ${user.email}] Deleting CSV file: ${filename}`); // Find config by file path const configs = await this.csvConfigRepository.findAll(); const config = configs.find((c) => c.csvFilePath === filename); if (!config) { throw new BadRequestException(`No configuration found for file: ${filename}`); } // Delete the file from filesystem const filePath = path.join( process.cwd(), 'apps/backend/src/infrastructure/storage/csv-storage/rates', filename ); try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); this.logger.log(`Deleted file: ${filePath}`); } } catch (error: any) { this.logger.error(`Failed to delete file ${filePath}: ${error.message}`); } // Delete from MinIO/S3 if it exists there const minioObjectKey = config.metadata?.minioObjectKey as string | undefined; if (minioObjectKey) { try { const bucket = this.configService.get('AWS_S3_BUCKET', 'xpeditis-csv-rates'); await this.s3Storage.delete({ bucket, key: minioObjectKey }); this.logger.log(`✅ Deleted file from MinIO: ${bucket}/${minioObjectKey}`); } catch (error: any) { this.logger.error(`⚠️ Failed to delete file from MinIO: ${error.message}`); // Don't fail the operation if MinIO deletion fails } } // Delete the configuration await this.csvConfigRepository.delete(config.companyName); this.logger.log(`Deleted CSV config and file for: ${config.companyName}`); return { success: true, message: `File ${filename} deleted successfully`, }; } }