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
- 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
549 lines
17 KiB
TypeScript
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`,
|
|
};
|
|
}
|
|
}
|