xpeditis2.0/apps/backend/src/application/controllers/admin/csv-rates.controller.ts
David f9b1625e20
All checks were successful
CI/CD Pipeline / Backend - Build, Test & Push (push) Successful in 7m10s
CI/CD Pipeline / Frontend - Build, Test & Push (push) Successful in 11m27s
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Deployment Summary (push) Successful in 1s
CI/CD Pipeline / Discord Notification (Failure) (push) Has been skipped
CI/CD Pipeline / Discord Notification (Success) (push) Successful in 2s
fix: replace require() with ES6 imports for fs and path
- Add fs and path imports at top of file
- Remove inline require() statements that violated ESLint rules
- Fixes 6 @typescript-eslint/no-var-requires errors
2025-11-17 23:26:22 +01:00

549 lines
17 KiB
TypeScript

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<CsvRateUploadResponseDto> {
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<string>('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<CsvRateConfigDto[]> {
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<CsvRateConfigDto> {
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<CsvFileValidationDto> {
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<void> {
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<string>('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`,
};
}
}