feat: add MinIO storage support for CSV rate files
- Upload CSV files to MinIO/S3 after validation - Store MinIO object key in database metadata - Support loading CSV from MinIO with fallback to local files - Delete from both MinIO and local storage when removing files - Add migration script to upload existing CSV files to MinIO - Graceful degradation if MinIO is not configured
This commit is contained in:
parent
e030871b4e
commit
753cfae41d
@ -6,7 +6,26 @@
|
|||||||
"Bash(npm run lint:*)",
|
"Bash(npm run lint:*)",
|
||||||
"Bash(npm run backend:lint)",
|
"Bash(npm run backend:lint)",
|
||||||
"Bash(npm run backend:build:*)",
|
"Bash(npm run backend:build:*)",
|
||||||
"Bash(npm run frontend:build:*)"
|
"Bash(npm run frontend:build:*)",
|
||||||
|
"Bash(rm:*)",
|
||||||
|
"Bash(git rm:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npx nest:*)",
|
||||||
|
"Read(//Users/david/Documents/xpeditis/**)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(npm test)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(git reset:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Read(//private/tmp/**)",
|
||||||
|
"Bash(lsof:*)",
|
||||||
|
"Bash(awk:*)",
|
||||||
|
"Bash(xargs kill:*)",
|
||||||
|
"Read(//dev/**)",
|
||||||
|
"Bash(psql:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import {
|
|||||||
CsvFileValidationDto,
|
CsvFileValidationDto,
|
||||||
} from '../../dto/csv-rate-upload.dto';
|
} from '../../dto/csv-rate-upload.dto';
|
||||||
import { CsvRateMapper } from '../../mappers/csv-rate.mapper';
|
import { CsvRateMapper } from '../../mappers/csv-rate.mapper';
|
||||||
|
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rates Admin Controller
|
* CSV Rates Admin Controller
|
||||||
@ -57,7 +59,9 @@ export class CsvRatesAdminController {
|
|||||||
private readonly csvLoader: CsvRateLoaderAdapter,
|
private readonly csvLoader: CsvRateLoaderAdapter,
|
||||||
private readonly csvConverter: CsvConverterService,
|
private readonly csvConverter: CsvConverterService,
|
||||||
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
||||||
private readonly csvRateMapper: CsvRateMapper
|
private readonly csvRateMapper: CsvRateMapper,
|
||||||
|
private readonly s3Storage: S3StorageAdapter,
|
||||||
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -197,6 +201,34 @@ export class CsvRatesAdminController {
|
|||||||
fs.renameSync(filePathToValidate, finalPath);
|
fs.renameSync(filePathToValidate, finalPath);
|
||||||
this.logger.log(`Renamed ${file.filename} to ${finalFilename}`);
|
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
|
// Check if config exists for this company
|
||||||
const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName);
|
const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName);
|
||||||
|
|
||||||
@ -211,6 +243,7 @@ export class CsvRatesAdminController {
|
|||||||
metadata: {
|
metadata: {
|
||||||
...existingConfig.metadata,
|
...existingConfig.metadata,
|
||||||
companyEmail: dto.companyEmail, // Store email in metadata
|
companyEmail: dto.companyEmail, // Store email in metadata
|
||||||
|
minioObjectKey, // Store MinIO object key
|
||||||
lastUpload: {
|
lastUpload: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
by: user.email,
|
by: user.email,
|
||||||
@ -237,6 +270,7 @@ export class CsvRatesAdminController {
|
|||||||
uploadedBy: user.email,
|
uploadedBy: user.email,
|
||||||
description: `${dto.companyName} shipping rates`,
|
description: `${dto.companyName} shipping rates`,
|
||||||
companyEmail: dto.companyEmail, // Store email in metadata
|
companyEmail: dto.companyEmail, // Store email in metadata
|
||||||
|
minioObjectKey, // Store MinIO object key
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -492,6 +526,19 @@ export class CsvRatesAdminController {
|
|||||||
this.logger.error(`Failed to delete file ${filePath}: ${error.message}`);
|
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
|
// Delete the configuration
|
||||||
await this.csvConfigRepository.delete(config.companyName);
|
await this.csvConfigRepository.delete(config.companyName);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { parse } from 'csv-parse/sync';
|
import { parse } from 'csv-parse/sync';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@ -9,6 +10,8 @@ import { ContainerType } from '@domain/value-objects/container-type.vo';
|
|||||||
import { Money } from '@domain/value-objects/money.vo';
|
import { Money } from '@domain/value-objects/money.vo';
|
||||||
import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo';
|
import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo';
|
||||||
import { DateRange } from '@domain/value-objects/date-range.vo';
|
import { DateRange } from '@domain/value-objects/date-range.vo';
|
||||||
|
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
|
||||||
|
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Row Interface
|
* CSV Row Interface
|
||||||
@ -63,7 +66,11 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
['NVO Consolidation', 'nvo-consolidation.csv'],
|
['NVO Consolidation', 'nvo-consolidation.csv'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
constructor() {
|
constructor(
|
||||||
|
@Optional() private readonly s3Storage?: S3StorageAdapter,
|
||||||
|
@Optional() private readonly configService?: ConfigService,
|
||||||
|
@Optional() private readonly csvConfigRepository?: TypeOrmCsvRateConfigRepository
|
||||||
|
) {
|
||||||
// CSV files are stored in infrastructure/storage/csv-storage/rates/
|
// CSV files are stored in infrastructure/storage/csv-storage/rates/
|
||||||
// Use absolute path based on project root (works in both dev and production)
|
// Use absolute path based on project root (works in both dev and production)
|
||||||
// In production, process.cwd() points to the backend app directory
|
// In production, process.cwd() points to the backend app directory
|
||||||
@ -77,18 +84,50 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
'rates'
|
'rates'
|
||||||
);
|
);
|
||||||
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
|
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
|
||||||
|
|
||||||
|
if (this.s3Storage && this.configService) {
|
||||||
|
this.logger.log('✅ MinIO/S3 storage support enabled for CSV files');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise<CsvRate[]> {
|
async loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise<CsvRate[]> {
|
||||||
this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`);
|
this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read CSV file
|
let fileContent: string;
|
||||||
const fullPath = path.isAbsolute(filePath)
|
|
||||||
? filePath
|
|
||||||
: path.join(this.csvDirectory, filePath);
|
|
||||||
|
|
||||||
const fileContent = await fs.readFile(fullPath, 'utf-8');
|
// Try to load from MinIO first if configured
|
||||||
|
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
|
||||||
|
try {
|
||||||
|
const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride);
|
||||||
|
const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined;
|
||||||
|
|
||||||
|
if (minioObjectKey) {
|
||||||
|
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
|
||||||
|
this.logger.log(`📥 Loading CSV from MinIO: ${bucket}/${minioObjectKey}`);
|
||||||
|
|
||||||
|
const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey });
|
||||||
|
fileContent = buffer.toString('utf-8');
|
||||||
|
this.logger.log(`✅ Successfully loaded CSV from MinIO`);
|
||||||
|
} else {
|
||||||
|
// Fallback to local file
|
||||||
|
throw new Error('No MinIO object key found, using local file');
|
||||||
|
}
|
||||||
|
} catch (minioError: any) {
|
||||||
|
this.logger.warn(`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`);
|
||||||
|
// Fallback to local file system
|
||||||
|
const fullPath = path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.join(this.csvDirectory, filePath);
|
||||||
|
fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Read from local file system
|
||||||
|
const fullPath = path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.join(this.csvDirectory, filePath);
|
||||||
|
fileContent = await fs.readFile(fullPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
// Parse CSV
|
// Parse CSV
|
||||||
const records: CsvRow[] = parse(fileContent, {
|
const records: CsvRow[] = parse(fileContent, {
|
||||||
|
|||||||
118
apps/backend/src/scripts/migrate-csv-to-minio.ts
Normal file
118
apps/backend/src/scripts/migrate-csv-to-minio.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../app.module';
|
||||||
|
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
|
||||||
|
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to migrate existing CSV files to MinIO
|
||||||
|
* Usage: npm run ts-node src/scripts/migrate-csv-to-minio.ts
|
||||||
|
*/
|
||||||
|
async function migrateCsvFilesToMinio() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule);
|
||||||
|
const s3Storage = app.get(S3StorageAdapter);
|
||||||
|
const csvConfigRepository = app.get(TypeOrmCsvRateConfigRepository);
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🚀 Starting CSV migration to MinIO...\n');
|
||||||
|
|
||||||
|
const bucket = configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
|
||||||
|
const csvDirectory = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'src',
|
||||||
|
'infrastructure',
|
||||||
|
'storage',
|
||||||
|
'csv-storage',
|
||||||
|
'rates'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all CSV configurations
|
||||||
|
const configs = await csvConfigRepository.findAll();
|
||||||
|
console.log(`📋 Found ${configs.length} CSV configurations\n`);
|
||||||
|
|
||||||
|
let migratedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const filename = config.csvFilePath;
|
||||||
|
const filePath = path.join(csvDirectory, filename);
|
||||||
|
|
||||||
|
console.log(`📄 Processing: ${config.companyName} - ${filename}`);
|
||||||
|
|
||||||
|
// Check if already in MinIO
|
||||||
|
const existingMinioKey = config.metadata?.minioObjectKey as string | undefined;
|
||||||
|
if (existingMinioKey) {
|
||||||
|
console.log(` ⏭️ Already in MinIO: ${existingMinioKey}`);
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists locally
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.log(` ⚠️ Local file not found: ${filePath}`);
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read local file
|
||||||
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
|
const objectKey = `csv-rates/${filename}`;
|
||||||
|
|
||||||
|
// Upload to MinIO
|
||||||
|
await s3Storage.upload({
|
||||||
|
bucket,
|
||||||
|
key: objectKey,
|
||||||
|
body: fileBuffer,
|
||||||
|
contentType: 'text/csv',
|
||||||
|
metadata: {
|
||||||
|
companyName: config.companyName,
|
||||||
|
uploadedBy: 'migration-script',
|
||||||
|
migratedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update configuration with MinIO object key
|
||||||
|
await csvConfigRepository.update(config.id, {
|
||||||
|
metadata: {
|
||||||
|
...config.metadata,
|
||||||
|
minioObjectKey: objectKey,
|
||||||
|
migratedToMinioAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✅ Uploaded to MinIO: ${bucket}/${objectKey}`);
|
||||||
|
migratedCount++;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(` ❌ Error uploading ${filename}: ${error.message}`);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('📊 Migration Summary:');
|
||||||
|
console.log(` ✅ Migrated: ${migratedCount}`);
|
||||||
|
console.log(` ⏭️ Skipped (already in MinIO): ${skippedCount}`);
|
||||||
|
console.log(` ❌ Errors: ${errorCount}`);
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
if (migratedCount > 0) {
|
||||||
|
console.log('🎉 Migration completed successfully!');
|
||||||
|
} else if (skippedCount === configs.length) {
|
||||||
|
console.log('✅ All files are already in MinIO');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Migration completed with errors');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Migration failed:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateCsvFilesToMinio();
|
||||||
Loading…
Reference in New Issue
Block a user