Merge branch 'develop' of https://github.com/Cameleonapp/dcb-service-merchant-config into develop
This commit is contained in:
commit
2cfce5ef99
60
.env-sample
Normal file
60
.env-sample
Normal file
@ -0,0 +1,60 @@
|
||||
# .env
|
||||
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# === CONFIGURATION DES TESTS STARTUP ===
|
||||
RUN_STARTUP_TESTS=false
|
||||
TEST_CLEANUP_DELAY_MS=100
|
||||
TEST_TIMEOUT_MS=30000
|
||||
TEST_USER_PASSWORD=SecureTempPass123!
|
||||
TEST_EMAIL_DOMAIN=dcb-test.com
|
||||
TEST_DEFAULT_PASSWORD=SecureTempPass123!
|
||||
|
||||
# === CONFIGURATION DE SÉCURITÉ ===
|
||||
RUN_SECURITY_TESTS=false
|
||||
SECURITY_TEST_TIMEOUT=300000
|
||||
|
||||
# === VALIDATION DES ENTREES ===
|
||||
MAX_USERNAME_LENGTH=50
|
||||
MIN_USERNAME_LENGTH=3
|
||||
ALLOWED_EMAIL_DOMAINS=dcb-test.com,pixpay.sn
|
||||
|
||||
# === RATE LIMITING ===
|
||||
MAX_REQUESTS_PER_MINUTE=60
|
||||
RATE_LIMIT_BLOCK_DURATION=300000
|
||||
|
||||
# === SÉCURITÉ DES SESSIONS ===
|
||||
SESSION_TIMEOUT=900000
|
||||
JWT_EXPIRATION=3600000
|
||||
|
||||
# === SURVEILLANCE ===
|
||||
LOG_SECURITY_EVENTS=true
|
||||
SECURITY_EVENT_RETENTION_DAYS=30
|
||||
|
||||
# === CONFIGURATION KEYCLOAK ===
|
||||
|
||||
KEYCLOAK_SERVER_URL=https://iam.dcb.pixpay.sn
|
||||
KEYCLOAK_REALM=dcb-prod
|
||||
|
||||
KEYCLOAK_JWKS_URI=https://iam.dcb.pixpay.sn/realms/dcb-prod/protocol/openid-connect/certs
|
||||
KEYCLOAK_ISSUER=https://iam.dcb.pixpay.sn/realms/dcb-prod
|
||||
|
||||
KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA01nspe5Sol9YAzm98wnQO1MvhRgJZSaOhozOHJEBm5VW5wLEEfcTlakzr/xXRjFYB9jySeaDWyhE6qGKuRK2Kx20qt3CuwT52ZSy97dKjJbgCxBCOymxKLJRdDfwtKOAayk5oCHqGp+cJTShnd9jVggYyTdqGqMWlpeiBKqvpgyldndwIfvDxPpPwsx/mwKV7S4sSTsONxSIB6zK+RumeYKOF0BskIxBw4tG3V5eicrECCKX/jP8rYFclBPXhxnLbbaHa21XAwQHfOioip3YfwPYF9GKTJEhM8ziJdTKikAtiwFm/Zvn1foLaF1MDLpV9yLrK0H1oa3y7j5p7tqHbQIDAQAB
|
||||
|
||||
KEYCLOAK_CLIENT_ID=dcb-user-service-cc-app
|
||||
KEYCLOAK_CLIENT_SECRET=IFNQWjBbcW6dXqQO76X5OZb1lL0esO30
|
||||
|
||||
KEYCLOAK_VALIDATION_MODE=offline
|
||||
|
||||
KEYCLOAK_TOKEN_BUFFER_SECONDS=30
|
||||
|
||||
KEYCLOAK_TEST_USER_ADMIN=bo-admin
|
||||
KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025
|
||||
|
||||
KEYCLOAK_TEST_USER_MERCHANT=bo-partner
|
||||
KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOPartner2025
|
||||
|
||||
KEYCLOAK_TEST_USER_SUPPORT=bo-support
|
||||
KEYCLOAK_TEST_PASSWORD=@BOSupport2025
|
||||
|
||||
2219
package-lock.json
generated
2219
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -22,21 +22,27 @@
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/bull": "^11.0.4",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/cache-manager": "^3.1.0",
|
||||
"@nestjs/common": "^11.1.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/core": "^11.1.11",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.1",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@nestjs/swagger": "^11.2.1",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@prisma/client": "^6.17.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-store": "^3.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"joi": "^18.0.2",
|
||||
"keycloak-connect": "^26.1.1",
|
||||
"minio": "^8.0.6",
|
||||
"nest-keycloak-connect": "^1.10.1",
|
||||
"passport-headerapikey": "^1.2.2",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@ -50,6 +56,7 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
|
||||
@ -6,19 +6,72 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import * as redisStore from 'cache-manager-redis-store';
|
||||
|
||||
import {
|
||||
KeycloakConnectModule,
|
||||
ResourceGuard,
|
||||
RoleGuard,
|
||||
AuthGuard,
|
||||
KeycloakConnectConfig,
|
||||
TokenValidation,
|
||||
} from 'nest-keycloak-connect';
|
||||
import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config';
|
||||
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { MinioModule } from './minio/minio.module';
|
||||
import { ImageModule } from './image/image.module';
|
||||
|
||||
// Import des configurations
|
||||
|
||||
// Import des modules
|
||||
import { PrismaService } from './shared/services/prisma.service';
|
||||
import { MerchantModule } from './merchant/merchant.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Configuration Module
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
//load: [],
|
||||
load: [keycloakConfig],
|
||||
validationSchema: keycloakConfigValidationSchema,
|
||||
validationOptions: {
|
||||
allowUnknown: false,
|
||||
abortEarly: true,
|
||||
},
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
// Keycloak Connect Module (Async configuration)
|
||||
KeycloakConnectModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService): KeycloakConnectConfig => {
|
||||
const keycloakConfig = configService.get('keycloak');
|
||||
|
||||
return {
|
||||
authServerUrl: keycloakConfig.serverUrl,
|
||||
realm: keycloakConfig.realm,
|
||||
clientId: keycloakConfig.authClientId,
|
||||
secret: keycloakConfig.authClientSecret,
|
||||
useNestLogger: true,
|
||||
|
||||
/**
|
||||
* Validation OFFLINE :
|
||||
* Le token JWT est validé localement (RS256 signature)
|
||||
* Aucun appel réseau vers Keycloak pour introspection.
|
||||
*/
|
||||
tokenValidation: TokenValidation.OFFLINE,
|
||||
|
||||
// Optional: Add more Keycloak options as needed
|
||||
// publicClient: false,
|
||||
// verifyTokenAudience: true,
|
||||
// confidentialPort: 0,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
MinioModule,
|
||||
ImageModule,
|
||||
|
||||
CacheModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
@ -33,9 +86,27 @@ import { MerchantModule } from './merchant/merchant.module';
|
||||
inject: [ConfigService],
|
||||
isGlobal: true,
|
||||
}),
|
||||
HealthModule,
|
||||
MerchantModule
|
||||
],
|
||||
providers: [PrismaService],
|
||||
providers: [
|
||||
PrismaService,
|
||||
// Global Authentication Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
// Global Resource Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ResourceGuard,
|
||||
},
|
||||
// Global Role Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RoleGuard,
|
||||
},
|
||||
],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
87
src/config/keycloak.config.ts
Normal file
87
src/config/keycloak.config.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
export interface KeycloakConfig {
|
||||
serverUrl: string;
|
||||
realm: string;
|
||||
publicKey?: string;
|
||||
authClientId: string;
|
||||
authClientSecret: string;
|
||||
validationMode: string;
|
||||
tokenBufferSeconds: number;
|
||||
}
|
||||
|
||||
export default registerAs('keycloak', (): KeycloakConfig => ({
|
||||
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://iam.dcb.pixpay.sn',
|
||||
realm: process.env.KEYCLOAK_REALM || 'dcb-prod',
|
||||
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
|
||||
authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-cc-app',
|
||||
authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
|
||||
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
|
||||
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30,
|
||||
}));
|
||||
|
||||
|
||||
export const keycloakConfigValidationSchema = Joi.object({
|
||||
KEYCLOAK_SERVER_URL: Joi.string()
|
||||
.uri()
|
||||
.required()
|
||||
.messages({
|
||||
'string.uri': 'KEYCLOAK_SERVER_URL must be a valid URL',
|
||||
'any.required': 'KEYCLOAK_SERVER_URL is required'
|
||||
}),
|
||||
|
||||
KEYCLOAK_REALM: Joi.string()
|
||||
.required()
|
||||
.pattern(/^[a-zA-Z0-9_-]+$/)
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_REALM is required',
|
||||
'string.pattern.base': 'KEYCLOAK_REALM can only contain letters, numbers, underscores and hyphens'
|
||||
}),
|
||||
|
||||
KEYCLOAK_PUBLIC_KEY: Joi.string()
|
||||
.required()
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_PUBLIC_KEY is required'
|
||||
}),
|
||||
|
||||
KEYCLOAK_CLIENT_ID: Joi.string()
|
||||
.required()
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_CLIENT_ID is required'
|
||||
}),
|
||||
|
||||
KEYCLOAK_CLIENT_SECRET: Joi.string()
|
||||
.required()
|
||||
.min(1)
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_CLIENT_SECRET is required',
|
||||
'string.min': 'KEYCLOAK_CLIENT_SECRET cannot be empty'
|
||||
}),
|
||||
|
||||
KEYCLOAK_VALIDATION_MODE: Joi.string()
|
||||
.required()
|
||||
.min(1)
|
||||
.messages({
|
||||
'any.required': 'KEYCLOAK_VALIDATION_MODE is required',
|
||||
'string.min': 'KEYCLOAK_VALIDATION_MODE cannot be empty'
|
||||
}),
|
||||
|
||||
KEYCLOAK_TOKEN_BUFFER_SECONDS: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.max(300)
|
||||
.default(30)
|
||||
.messages({
|
||||
'number.max': 'KEYCLOAK_TOKEN_BUFFER_SECONDS cannot exceed 300 seconds'
|
||||
}),
|
||||
|
||||
// Variables d'environnement générales
|
||||
NODE_ENV: Joi.string()
|
||||
.valid('development', 'production', 'test', 'staging')
|
||||
.default('development'),
|
||||
|
||||
PORT: Joi.number()
|
||||
.port()
|
||||
.default(3000),
|
||||
}).unknown(true);
|
||||
52
src/health/health.controller.ts
Normal file
52
src/health/health.controller.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Controller, Get, Head, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
/**
|
||||
* Endpoint HEAD pour les vérifications rapides
|
||||
* Retourne seulement les headers, pas de body
|
||||
*/
|
||||
@Head()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
headCheck() {
|
||||
// HEAD ne retourne pas de body, juste le status 200
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint GET pour les vérifications complètes
|
||||
* Retourne les informations détaillées
|
||||
*/
|
||||
@Get()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
getCheck() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
service: 'API',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint ping simple
|
||||
*/
|
||||
@Get('ping')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
ping() {
|
||||
return {
|
||||
status: 'ok',
|
||||
message: 'pong',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint HEAD pour ping
|
||||
*/
|
||||
@Head('ping')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
headPing() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
9
src/health/health.module.ts
Normal file
9
src/health/health.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
controllers: [HealthController]
|
||||
})
|
||||
export class HealthModule {}
|
||||
226
src/image/image.controller.ts
Normal file
226
src/image/image.controller.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
BadRequestException,
|
||||
Request,
|
||||
Query,
|
||||
Body,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ImageService } from './service/image.service';
|
||||
|
||||
export interface ImageUploadResponse {
|
||||
message : string;
|
||||
success: boolean;
|
||||
merchant: {
|
||||
id: string,
|
||||
name: string
|
||||
};
|
||||
data: {
|
||||
fileName: string;
|
||||
url: string;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadedAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('images')
|
||||
export class ImageController {
|
||||
constructor(private readonly imageService: ImageService) {}
|
||||
|
||||
/**
|
||||
* POST /merchants/:merchantId/logos/upload
|
||||
* Upload un logo pour un marchand
|
||||
*/
|
||||
@Post('merchants/:merchantId/logos/upload')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadLogo(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Body() body: { merchantName: string },
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Request() req,
|
||||
@Query('maxSizeMB') maxSizeMB?: string
|
||||
): Promise<ImageUploadResponse> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Aucun fichier fourni');
|
||||
}
|
||||
|
||||
const userId = req.user?.sub || req.user?.userId || 'anonymous';
|
||||
|
||||
const merchantName = body.merchantName;
|
||||
|
||||
try {
|
||||
const options = {
|
||||
maxSizeMB: maxSizeMB ? parseInt(maxSizeMB) : undefined,
|
||||
validateType: true,
|
||||
metadata: {
|
||||
merchantName: merchantName
|
||||
}
|
||||
};
|
||||
|
||||
const result = await this.imageService.uploadMerchantLogo(
|
||||
file,
|
||||
userId,
|
||||
merchantId,
|
||||
merchantName,
|
||||
options
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logo uploadé avec succès',
|
||||
merchant: {
|
||||
id: merchantId,
|
||||
name: merchantName
|
||||
},
|
||||
data: result
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Échec de l'upload: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /merchants/:merchantId/logos
|
||||
* Liste tous les logos d'un marchand
|
||||
*/
|
||||
@Get('merchants/:merchantId/logos')
|
||||
async listLogos(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Query('merchantName') merchantName: string
|
||||
) {
|
||||
try {
|
||||
const logos = await this.imageService.listMerchantLogos(merchantId, merchantName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
merchant: {
|
||||
id: merchantId,
|
||||
name: merchantName
|
||||
},
|
||||
count: logos.length,
|
||||
data: logos
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Échec de la récupération des logos: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /merchants/:merchantId/logos/url
|
||||
* Récupère l'URL d'un logo (presigned ou non)
|
||||
*/
|
||||
@Get('merchants/:merchantId/logos/url')
|
||||
async getMerchantLogoUrl(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Query('fileName') fileName: string,
|
||||
@Query('signed') signed?: string,
|
||||
@Query('expiry') expiry?: string
|
||||
) {
|
||||
if (!fileName) {
|
||||
throw new BadRequestException('fileName requis');
|
||||
}
|
||||
|
||||
try {
|
||||
// Sécurité minimale : vérifier que le fichier appartient bien au merchant
|
||||
if (!fileName.startsWith(`merchants/${merchantId}_`)) {
|
||||
throw new BadRequestException('Accès non autorisé à ce fichier');
|
||||
}
|
||||
|
||||
const url =
|
||||
signed === 'true'
|
||||
? await this.imageService.getMerchantLogoSignedUrl(
|
||||
fileName,
|
||||
expiry ? parseInt(expiry, 10) : 3600
|
||||
)
|
||||
: await this.imageService.getMerchantLogoUrl(fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
merchantId,
|
||||
data: {
|
||||
fileName,
|
||||
url
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
`Échec de la récupération de l'URL: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /merchants/:merchantId/logos/:fileName
|
||||
* Récupère l'URL d'un logo spécifique
|
||||
*/
|
||||
@Get('info/:fileName')
|
||||
async getLogoInfo(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Param('fileName') fileName: string,
|
||||
@Query('merchantName') merchantName: string,
|
||||
@Query('signed') signed?: string,
|
||||
@Query('expiry') expiry?: string
|
||||
) {
|
||||
try {
|
||||
|
||||
const info = await this.imageService.getLogoInfo(fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
merchant: {
|
||||
id: merchantId,
|
||||
name: merchantName
|
||||
},
|
||||
data:info
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Échec de la récupération des infos: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /merchants/:merchantId/logos/:fileName
|
||||
* Supprime un logo
|
||||
*/
|
||||
@Delete('merchants/:merchantId/logos/url')
|
||||
async deleteLogo(
|
||||
@Param('merchantId') merchantId: string,
|
||||
@Query('fileName') fileName: string
|
||||
) {
|
||||
if (!fileName) {
|
||||
throw new BadRequestException('fileName requis');
|
||||
}
|
||||
|
||||
try {
|
||||
// Sécurité minimale : vérifier que le fichier appartient bien au merchant
|
||||
if (!fileName.startsWith(`merchants/${merchantId}_`)) {
|
||||
throw new BadRequestException('Accès non autorisé à ce fichier');
|
||||
}
|
||||
|
||||
await this.imageService.deleteMerchantLogo(fileName);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logo supprimé avec succès',
|
||||
merchantId: merchantId,
|
||||
deletedFile: fileName
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new BadRequestException(`Échec de la suppression: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
9
src/image/image.module.ts
Normal file
9
src/image/image.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ImageController } from './image.controller';
|
||||
import { ImageService } from './service/image.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ImageController],
|
||||
providers: [ImageService],
|
||||
})
|
||||
export class ImageModule {}
|
||||
365
src/image/service/image.service.ts
Normal file
365
src/image/service/image.service.ts
Normal file
@ -0,0 +1,365 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { MinioService, UploadResult, UploadOptions } from '../../minio/service/minio.service';
|
||||
|
||||
export interface ImageUploadOptions {
|
||||
maxSizeMB?: number;
|
||||
metadata?: Record<string, string>;
|
||||
validateType?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageUploadResponse {
|
||||
success: boolean;
|
||||
fileName: string;
|
||||
url: string;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadedAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ImageService {
|
||||
private readonly logger = new Logger(ImageService.name);
|
||||
|
||||
constructor(private readonly minioService: MinioService) {}
|
||||
|
||||
/**
|
||||
* Upload une image pour un marchand
|
||||
*/
|
||||
async uploadMerchantLogo(
|
||||
file: Express.Multer.File,
|
||||
userId: string,
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
options: ImageUploadOptions = {}
|
||||
): Promise<ImageUploadResponse> {
|
||||
try {
|
||||
const {
|
||||
maxSizeMB = 5,
|
||||
metadata = {},
|
||||
validateType = true
|
||||
} = options;
|
||||
|
||||
this.logger.log(`🖼️ Uploading logo for merchant : ${merchantName} ID: ${merchantId}`);
|
||||
this.logger.log(` File: ${file.originalname} (${file.size} bytes)`);
|
||||
this.logger.log(` Type: ${file.mimetype}`);
|
||||
this.logger.log(` User: ${userId}`);
|
||||
|
||||
// Validation de l'image
|
||||
this.validateImage(file, maxSizeMB, validateType);
|
||||
|
||||
// Générer un nom de fichier sécurisé
|
||||
const fileName = this.generateImageFileName(merchantId, merchantName, file.originalname);
|
||||
|
||||
// Préparer les métadonnées
|
||||
const customMetadata = this.createImageMetadata(userId, merchantId, merchantName, file, metadata);
|
||||
|
||||
// Options d'upload
|
||||
const uploadOptions: UploadOptions = {
|
||||
maxSizeMB,
|
||||
expirySeconds: 3600,
|
||||
customMetadata,
|
||||
validateContentType: validateType
|
||||
};
|
||||
|
||||
// Upload via MinIO
|
||||
const uploadResult = await this.minioService.uploadImage(
|
||||
file.buffer,
|
||||
fileName,
|
||||
file.mimetype,
|
||||
userId,
|
||||
merchantId,
|
||||
merchantName,
|
||||
uploadOptions
|
||||
);
|
||||
|
||||
// Convertir en format de réponse standard
|
||||
const response: ImageUploadResponse = {
|
||||
success: uploadResult.success,
|
||||
fileName: uploadResult.fileName,
|
||||
url: uploadResult.fileUrl,
|
||||
publicUrl: uploadResult.publicUrl,
|
||||
downloadUrl: uploadResult.downloadUrl,
|
||||
size: uploadResult.size,
|
||||
contentType: uploadResult.contentType,
|
||||
uploadedAt: uploadResult.uploadedAt
|
||||
};
|
||||
|
||||
this.logger.log(`✅ Logo uploaded successfully: ${fileName}`);
|
||||
this.logger.log(` Public URL: ${response.publicUrl}`);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to upload logo: ${error.message}`, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL publique d'un logo
|
||||
*/
|
||||
async getMerchantLogoUrl(fileName: string): Promise<string> {
|
||||
try {
|
||||
this.logger.log(`🔗 Getting logo URL: ${fileName}`);
|
||||
|
||||
const url = await this.minioService.getPublicUrl(fileName);
|
||||
|
||||
this.logger.log(`✅ Logo URL retrieved`);
|
||||
return url;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to get logo URL: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une URL signée temporaire pour un logo
|
||||
*/
|
||||
async getMerchantLogoSignedUrl(fileName: string, expirySeconds = 3600): Promise<string> {
|
||||
try {
|
||||
|
||||
this.logger.log(`🔗 Generating signed URL for logo: ${fileName}`);
|
||||
|
||||
const url = await this.minioService.getFileUrl(fileName, expirySeconds);
|
||||
|
||||
this.logger.log(`✅ Signed URL generated (valid for ${expirySeconds}s)`);
|
||||
return url;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to generate signed URL: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un logo
|
||||
*/
|
||||
async deleteMerchantLogo(fileName: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`🗑️ Deleting logo: ${fileName}`);
|
||||
|
||||
await this.minioService.deleteFile(fileName);
|
||||
|
||||
this.logger.log(`✅ Logo deleted: ${fileName}`);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to delete logo: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un logo existe
|
||||
*/
|
||||
async logoExists(fileName: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.minioService.fileExists(fileName);
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to check if logo exists: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les informations d'un logo
|
||||
*/
|
||||
async getLogoInfo(fileName: string): Promise<ImageUploadResponse> {
|
||||
try {
|
||||
this.logger.log(`📋 Getting logo info: ${fileName}`);
|
||||
|
||||
const fileInfo = await this.minioService.getFileInfo(fileName);
|
||||
const publicUrl = await this.minioService.getPublicUrl(fileName);
|
||||
const downloadUrl = await this.minioService.getFileUrl(fileName);
|
||||
|
||||
const response: ImageUploadResponse = {
|
||||
success: true,
|
||||
fileName,
|
||||
url: downloadUrl,
|
||||
publicUrl,
|
||||
downloadUrl,
|
||||
size: fileInfo.size,
|
||||
contentType: fileInfo.contentType || 'image/jpeg',
|
||||
uploadedAt: fileInfo.lastModified || new Date()
|
||||
};
|
||||
|
||||
this.logger.log(`✅ Logo info retrieved`);
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to get logo info: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste tous les logos d'un marchand
|
||||
*/
|
||||
async listMerchantLogos(merchantId: string, merchantName: string): Promise<ImageUploadResponse[]> {
|
||||
try {
|
||||
this.logger.log(`📂 Listing logos for merchant: ${merchantName} ID: ${merchantId}`);
|
||||
|
||||
// Utiliser le pattern de chemin pour les logos du marchand
|
||||
const pathPrefix = `merchants/${merchantId}_${merchantName}/logos/`;
|
||||
|
||||
// Note: Vous devrez peut-être adapter listUserFiles ou créer une méthode spécifique
|
||||
// Pour l'instant, on utilise listUserFiles avec l'ID du marchand comme "utilisateur"
|
||||
const files = await this.minioService.listUserFiles(`merchants/${merchantId}_${merchantName}/`);
|
||||
|
||||
// Filtrer seulement les logos
|
||||
const logoFiles = files.filter(file =>
|
||||
file.includes('/logos/') &&
|
||||
this.isImageFile(file)
|
||||
);
|
||||
|
||||
// Récupérer les infos pour chaque logo
|
||||
const logos = await Promise.all(
|
||||
logoFiles.map(async (fileName) => {
|
||||
try {
|
||||
return await this.getLogoInfo(fileName);
|
||||
} catch (error) {
|
||||
this.logger.warn(`⚠️ Failed to get info for logo ${fileName}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filtrer les nulls et trier par date (plus récent d'abord)
|
||||
const validLogos = logos.filter((logo): logo is ImageUploadResponse => logo !== null);
|
||||
validLogos.sort((a, b) => b.uploadedAt.getTime() - a.uploadedAt.getTime());
|
||||
|
||||
this.logger.log(`✅ Found ${validLogos.length} logo(s) for merchant ${merchantId}`);
|
||||
return validLogos;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to list merchant logos: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Méthodes utilitaires
|
||||
*/
|
||||
|
||||
private validateImage(
|
||||
file: Express.Multer.File,
|
||||
maxSizeMB: number,
|
||||
validateType: boolean
|
||||
): void {
|
||||
// Validation de la taille
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
||||
if (file.size > maxSizeBytes) {
|
||||
throw new Error(
|
||||
`Image too large. Maximum size: ${maxSizeMB}MB`
|
||||
);
|
||||
}
|
||||
|
||||
// Validation du type (optionnel)
|
||||
if (validateType) {
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml'
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.mimetype)) {
|
||||
throw new Error(
|
||||
`Invalid image type. Allowed types: ${allowedTypes.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private generateImageFileName(
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
originalFileName: string
|
||||
): string {
|
||||
|
||||
const timestamp = Date.now();
|
||||
const randomString = Math.random().toString(36).substring(2, 8);
|
||||
|
||||
const safeMerchantName = this.normalizeForPath(merchantName);
|
||||
const safeMerchantId = this.normalizeForPath(merchantId);
|
||||
|
||||
// Extension propre
|
||||
const extension = originalFileName.includes('.')
|
||||
? originalFileName.substring(originalFileName.lastIndexOf('.')).toLowerCase()
|
||||
: '';
|
||||
|
||||
return `merchants/${safeMerchantId}_${safeMerchantName}/logos/${timestamp}_${randomString}${extension}`;
|
||||
}
|
||||
|
||||
|
||||
normalizeForPath(value: string): string {
|
||||
return value
|
||||
.normalize('NFD') // sépare les accents
|
||||
.replace(/[\u0300-\u036f]/g, '') // supprime les accents
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_') // tout sauf a-z0-9 → _
|
||||
.replace(/^_+|_+$/g, '') // retire _ début/fin
|
||||
.replace(/_+/g, '_'); // évite ____
|
||||
}
|
||||
|
||||
private createImageMetadata(
|
||||
userId: string,
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
file: Express.Multer.File,
|
||||
additionalMetadata: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const baseMetadata: Record<string, string> = {
|
||||
'user-id': userId,
|
||||
'merchant-id': merchantId,
|
||||
'merchant-name': merchantName,
|
||||
'original-name': file.originalname,
|
||||
'image-type': file.mimetype.split('/')[1] || 'unknown',
|
||||
'uploaded-at': new Date().toISOString(),
|
||||
...additionalMetadata
|
||||
};
|
||||
|
||||
return baseMetadata;
|
||||
}
|
||||
|
||||
private isImageFile(fileName: string): boolean {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
|
||||
const lowerFileName = fileName.toLowerCase();
|
||||
return imageExtensions.some(ext => lowerFileName.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la taille d'un fichier en texte lisible
|
||||
*/
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un fichier est une image basée sur son extension
|
||||
*/
|
||||
isImageByExtension(fileName: string): boolean {
|
||||
return this.isImageFile(fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un type MIME est une image
|
||||
*/
|
||||
isImageMimeType(mimeType: string): boolean {
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml'
|
||||
];
|
||||
return allowedTypes.includes(mimeType);
|
||||
}
|
||||
}
|
||||
@ -20,7 +20,7 @@ async function bootstrap() {
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || '*',
|
||||
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
ParseIntPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
DefaultValuePipe,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { MerchantService } from '../services/merchant.service';
|
||||
@ -32,19 +34,23 @@ export class MerchantController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all merchants' })
|
||||
@ApiQuery({ name: 'skip', required: false, type: Number })
|
||||
@ApiQuery({ name: 'take', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, description: 'List of merchants' })
|
||||
findAll(
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
@ApiQuery({ name: 'skip', required: false, type: Number, example: 0 })
|
||||
@ApiQuery({ name: 'take', required: false, type: Number, example: 10 })
|
||||
@ApiResponse({ status: 200, description: 'Paginated merchants' })
|
||||
async findAll(
|
||||
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||
@Query('take', new DefaultValuePipe(10), ParseIntPipe) take: number,
|
||||
) {
|
||||
return this.merchantService.findAll(
|
||||
skip ? parseInt(skip) : 0,
|
||||
take ? parseInt(take) : 10,
|
||||
);
|
||||
|
||||
if (skip < 0) throw new BadRequestException('skip must be >= 0');
|
||||
if (take < 1 || take > 100) {
|
||||
throw new BadRequestException('take must be between 1 and 100');
|
||||
}
|
||||
|
||||
return this.merchantService.findAll(skip, take);
|
||||
}
|
||||
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get merchant by ID' })
|
||||
@ApiParam({ name: 'id', type: Number })
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { MerchantPartnerWithRelations, MerchantUserWithInfo } from '../entities/merchant.entity';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import type { UserServiceClient } from '../interfaces/ user.service.interface';
|
||||
import type { UserServiceClient } from '../interfaces/user.service.interface';
|
||||
import { CreateMerchantPartnerDto } from '../dto/create.merchant.dto';
|
||||
import { UpdateMerchantPartnerDto } from '../dto/ update.merchant.dto';
|
||||
import { AddUserToMerchantDto, UpdateUserRoleDto } from '../dto/merchant.user.dto';
|
||||
@ -97,23 +97,49 @@ export class MerchantService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all merchants with optional pagination
|
||||
* Find merchants with pagination and total count
|
||||
*/
|
||||
async findAll(skip = 0, take = 10): Promise<MerchantPartnerWithRelations[]> {
|
||||
const merchants = await this.prisma.merchantPartner.findMany({
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
configs: true,
|
||||
merchantUsers: true,
|
||||
technicalContacts: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
async findAll(skip = 0, take = 10): Promise<{ items: MerchantPartnerWithRelations[], total: number }> {
|
||||
if (skip < 0) throw new BadRequestException('skip must be >= 0');
|
||||
if (take < 1 || take > 100) throw new BadRequestException('take must be between 1 et 100');
|
||||
|
||||
return Promise.all(merchants.map(m => this.enrichMerchantWithUserInfo(m)));
|
||||
try {
|
||||
const total = await this.prisma.merchantPartner.count();
|
||||
|
||||
// Ajuster le take si on dépasse le total
|
||||
if (skip >= total) {
|
||||
return { items: [], total };
|
||||
}
|
||||
const remaining = total - skip;
|
||||
const adjustedTake = Math.min(take, remaining);
|
||||
|
||||
const merchants = await this.prisma.merchantPartner.findMany({
|
||||
skip,
|
||||
take: adjustedTake,
|
||||
include: {
|
||||
configs: true,
|
||||
merchantUsers: true,
|
||||
technicalContacts: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
const enrichedMerchants = await Promise.all(
|
||||
merchants.map(async merchant => {
|
||||
try {
|
||||
return await this.enrichMerchantWithUserInfo(merchant);
|
||||
} catch (e) {
|
||||
console.error(`⚠️ Error enriching merchant ${merchant.id}:`, e);
|
||||
return merchant;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { items: enrichedMerchants, total };
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch merchants:', error);
|
||||
throw new InternalServerErrorException('Failed to fetch merchants');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,48 +212,91 @@ export class MerchantService {
|
||||
* Add user to merchant
|
||||
*/
|
||||
async addUserToMerchant(dto: AddUserToMerchantDto): Promise<MerchantUserWithInfo> {
|
||||
// Check if merchant exists
|
||||
await this.findOne(dto.merchantPartnerId);
|
||||
console.log('🔹 Starting addUserToMerchant process', dto);
|
||||
|
||||
// Validate user exists
|
||||
const userExists = await this.userServiceClient.verifyUserExists(dto.userId);
|
||||
if (!userExists) {
|
||||
throw new BadRequestException(`User with ID ${dto.userId} not found in user service`);
|
||||
// 1️⃣ Vérifier que le merchant existe
|
||||
let merchant;
|
||||
try {
|
||||
merchant = await this.findOne(dto.merchantPartnerId);
|
||||
console.log(`✅ Merchant exists: ID=${dto.merchantPartnerId}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ Merchant not found: ID=${dto.merchantPartnerId}`, err);
|
||||
throw new BadRequestException(`Merchant with ID ${dto.merchantPartnerId} not found`);
|
||||
}
|
||||
|
||||
// Check if user already attached
|
||||
const existing = await this.prisma.merchantUser.findUnique({
|
||||
where: {
|
||||
userId_merchantPartnerId: {
|
||||
// 2️⃣ Vérifier que l'utilisateur existe dans le service utilisateur
|
||||
let userExists: boolean;
|
||||
try {
|
||||
userExists = await this.userServiceClient.verifyUserExists(dto.userId);
|
||||
console.log(`📌 User exists check for ID=${dto.userId}: ${userExists}`);
|
||||
if (!userExists) {
|
||||
throw new BadRequestException(`User with ID ${dto.userId} not found in user service`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`❌ Error verifying user existence: ID=${dto.userId}`, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 3️⃣ Vérifier si l'utilisateur est déjà attaché au merchant
|
||||
let existing;
|
||||
try {
|
||||
existing = await this.prisma.merchantUser.findUnique({
|
||||
where: {
|
||||
userId_merchantPartnerId: {
|
||||
userId: dto.userId,
|
||||
merchantPartnerId: dto.merchantPartnerId,
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(`📌 Existing association check:`, existing ? 'Exists' : 'Not exists');
|
||||
if (existing) {
|
||||
throw new ConflictException(`User ${dto.userId} is already attached to merchant ${dto.merchantPartnerId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`❌ Error checking existing merchant user: ID=${dto.userId}`, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 4️⃣ Créer l'association
|
||||
let merchantUser: MerchantUserWithInfo;
|
||||
try {
|
||||
merchantUser = await this.prisma.merchantUser.create({
|
||||
data: {
|
||||
userId: dto.userId,
|
||||
role: dto.role,
|
||||
merchantPartnerId: dto.merchantPartnerId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`User ${dto.userId} is already attached to merchant ${dto.merchantPartnerId}`);
|
||||
});
|
||||
console.log(`✅ MerchantUser created: ID=${merchantUser.id}, UserID=${dto.userId}, MerchantID=${dto.merchantPartnerId}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ Error creating merchantUser`, err);
|
||||
throw new InternalServerErrorException('Failed to create merchant user');
|
||||
}
|
||||
|
||||
// Create merchant user
|
||||
const merchantUser = await this.prisma.merchantUser.create({
|
||||
data: {
|
||||
// 5️⃣ Émettre l'événement
|
||||
try {
|
||||
this.eventEmitter.emit('merchant.user.attached', {
|
||||
merchantId: dto.merchantPartnerId,
|
||||
userId: dto.userId,
|
||||
role: dto.role,
|
||||
merchantPartnerId: dto.merchantPartnerId,
|
||||
},
|
||||
});
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`📣 Event emitted: merchant.user.attached`);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Failed to emit event for merchant.user.attached`, err);
|
||||
}
|
||||
|
||||
// Emit event
|
||||
this.eventEmitter.emit('merchant.user.attached', {
|
||||
merchantId: dto.merchantPartnerId,
|
||||
userId: dto.userId,
|
||||
role: dto.role,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
// 6️⃣ Récupérer les infos utilisateur enrichies
|
||||
let userInfo;
|
||||
try {
|
||||
userInfo = await this.userServiceClient.getUserInfo(dto.userId);
|
||||
console.log(`📌 User info fetched for ID=${dto.userId}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to fetch user info, returning merchantUser without enrichment`, err);
|
||||
userInfo = null;
|
||||
}
|
||||
|
||||
// Enrich with user info
|
||||
const userInfo = await this.userServiceClient.getUserInfo(dto.userId);
|
||||
// 7️⃣ Retourner l'objet final
|
||||
return {
|
||||
...merchantUser,
|
||||
userInfo,
|
||||
|
||||
@ -2,31 +2,137 @@ import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { UserInfo, UserServiceClient } from '../interfaces/ user.service.interface';
|
||||
|
||||
import { UserInfo, UserServiceClient } from '../interfaces/user.service.interface';
|
||||
import { KeycloakConfig } from 'src/config/keycloak.config';
|
||||
|
||||
@Injectable()
|
||||
export class HttpUserServiceClient implements UserServiceClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly keycloakConfig: KeycloakConfig;
|
||||
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>('USER_SERVICE_URL') || 'http://localhost:3001';
|
||||
this.keycloakConfig = this.getKeycloakConfig();
|
||||
this.baseUrl = this.configService.get<string>('USER_SERVICE') || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// === CONFIGURATION ===
|
||||
private getKeycloakConfig(): KeycloakConfig {
|
||||
const config = this.configService.get<KeycloakConfig>('keycloak');
|
||||
if (!config) {
|
||||
throw new Error('Keycloak configuration not found');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
// Vérifier si le token est encore valide (avec une marge de 30 secondes)
|
||||
if (this.accessToken !== null && Date.now() < this.tokenExpiry - 30000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenUrl = `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/token`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('grant_type', 'client_credentials');
|
||||
params.append('client_id', this.keycloakConfig.authClientId);
|
||||
params.append('client_secret', this.keycloakConfig.authClientSecret);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(tokenUrl, params.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
this.accessToken = response.data.access_token;
|
||||
// Calculer l'expiration du token (expires_in est en secondes)
|
||||
this.tokenExpiry = Date.now() + (response.data.expires_in * 1000);
|
||||
|
||||
return this.accessToken || '';
|
||||
} catch (error) {
|
||||
throw new HttpException(
|
||||
'Failed to authenticate with Keycloak',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const token = await this.getAccessToken();
|
||||
return {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async verifyUserExists(userId: string): Promise<boolean> {
|
||||
try {
|
||||
console.log(`🔍 [verifyUserExists] Vérification de l'utilisateur: ${userId}`);
|
||||
console.log(` Type de userId: ${typeof userId}`);
|
||||
console.log(` Valeur de userId: "${userId}"`);
|
||||
|
||||
const headers = await this.getAuthHeaders();
|
||||
|
||||
const url = `${this.baseUrl}/merchant-users/${userId}`;
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${this.baseUrl}/users/${userId}/exists`),
|
||||
this.httpService.get(url, { headers }),
|
||||
);
|
||||
return response.data.exists === true;
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
|
||||
console.log(`✅ [verifyUserExists] Réponse complète:`, JSON.stringify(response.data, null, 2));
|
||||
|
||||
// Vérifier si on a reçu une réponse valide
|
||||
if (!response.data) {
|
||||
console.log(` ❌ Aucune donnée dans la réponse`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// L'utilisateur existe si on a reçu une réponse 200 avec des données
|
||||
const exists = response.data && response.data.id === userId;
|
||||
console.log(` Résultat: ${exists ? '✅ Utilisateur existe' : '❌ Utilisateur non trouvé'}`);
|
||||
|
||||
return exists;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ [verifyUserExists] Erreur détaillée:`, {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
code: error.code
|
||||
});
|
||||
|
||||
if (error.response?.status === 404) {
|
||||
console.log(` 📭 Utilisateur ${userId} non trouvé (404)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
console.log(` 🔄 Token invalide (401), rafraîchissement...`);
|
||||
this.accessToken = null;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return this.verifyUserExists(userId);
|
||||
}
|
||||
|
||||
// Autres erreurs HTTP
|
||||
if (error.response?.status) {
|
||||
console.log(` ⚠️ Erreur HTTP ${error.response.status}: ${error.response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Erreur réseau ou autre
|
||||
console.log(` 🚨 Erreur non-HTTP: ${error.message}`);
|
||||
throw new HttpException(
|
||||
'Failed to verify user existence',
|
||||
`Failed to verify user existence: ${error.message}`,
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
@ -34,14 +140,19 @@ export class HttpUserServiceClient implements UserServiceClient {
|
||||
|
||||
async getUserInfo(userId: string): Promise<UserInfo> {
|
||||
try {
|
||||
const headers = await this.getAuthHeaders();
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${this.baseUrl}/users/${userId}`),
|
||||
this.httpService.get(`${this.baseUrl}/merchant-users/${userId}`, { headers }),
|
||||
);
|
||||
return this.mapToUserInfo(response.data);
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
throw new HttpException(`User ${userId} not found`, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (error.response?.status === 401) {
|
||||
this.accessToken = null;
|
||||
return this.getUserInfo(userId);
|
||||
}
|
||||
throw new HttpException(
|
||||
'Failed to get user information',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@ -55,11 +166,16 @@ export class HttpUserServiceClient implements UserServiceClient {
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = await this.getAuthHeaders();
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(`${this.baseUrl}/users/batch`, { userIds }),
|
||||
this.httpService.post(`${this.baseUrl}/merchant-users/batch`, { userIds }, { headers }),
|
||||
);
|
||||
return response.data.map(user => this.mapToUserInfo(user));
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
this.accessToken = null;
|
||||
return this.getUsersInfo(userIds);
|
||||
}
|
||||
throw new HttpException(
|
||||
'Failed to get users information',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
|
||||
9
src/minio/minio.module.ts
Normal file
9
src/minio/minio.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { MinioService } from './service/minio.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MinioService],
|
||||
exports: [MinioService],
|
||||
})
|
||||
export class MinioModule {}
|
||||
409
src/minio/service/minio.service.ts
Normal file
409
src/minio/service/minio.service.ts
Normal file
@ -0,0 +1,409 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import * as Minio from 'minio';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface UploadOptions {
|
||||
maxSizeMB?: number;
|
||||
expirySeconds?: number;
|
||||
customMetadata?: Record<string, string>;
|
||||
validateContentType?: boolean;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
uploadedAt: Date;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
originalName: string;
|
||||
buffer: Buffer;
|
||||
size: number;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export interface PresignedUploadSession {
|
||||
uploadUrl: string;
|
||||
fileName: string;
|
||||
formData: Record<string, string>;
|
||||
metadata: Record<string, string>;
|
||||
publicUrl: string;
|
||||
downloadUrl: string;
|
||||
expiresAt: Date;
|
||||
method: 'POST';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MinioService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MinioService.name);
|
||||
private minioClient: Minio.Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly endpoint: string;
|
||||
private readonly port: number;
|
||||
private readonly useSSL: boolean;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.bucketName = this.configService.get<string>('MINIO_BUCKET', 'bo-assets');
|
||||
this.endpoint = this.configService.get<string>('MINIO_ENDPOINT', 'localhost');
|
||||
this.port = Number(this.configService.get<string>('MINIO_API_PORT', '9000'));
|
||||
this.useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
|
||||
|
||||
const accessKey = this.configService.get<string>('MINIO_ROOT_USER', 'minioadmin');
|
||||
const secretKey = this.configService.get<string>('MINIO_ROOT_PASSWORD', 'minioadmin');
|
||||
|
||||
// Log de démarrage (sans afficher les credentials)
|
||||
this.logger.log(`🔧 Initializing MinIO client:`);
|
||||
this.logger.log(` Endpoint: ${this.endpoint}:${this.port}`);
|
||||
this.logger.log(` UseSSL: ${this.useSSL}`);
|
||||
this.logger.log(` Bucket: ${this.bucketName}`);
|
||||
this.logger.log(` Access Key: ${accessKey.substring(0, 3)}***`);
|
||||
|
||||
this.minioClient = new Minio.Client({
|
||||
endPoint: this.endpoint,
|
||||
port: this.port,
|
||||
useSSL: this.useSSL,
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
//await this.testConnection();
|
||||
await this.createBucketIfNotExists();
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to initialize MinIO: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à MinIO
|
||||
*/
|
||||
private async testConnection(): Promise<void> {
|
||||
try {
|
||||
this.logger.log('🔍 Testing MinIO connection...');
|
||||
|
||||
const buckets = await this.minioClient.listBuckets();
|
||||
|
||||
this.logger.log(`✅ MinIO connection successful! Found ${buckets.length} bucket(s)`);
|
||||
|
||||
buckets.forEach((bucket, index) => {
|
||||
this.logger.log(`🪣 [${index + 1}] ${bucket.name} (created: ${bucket.creationDate})`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ MinIO connection failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Crée le bucket s'il n'existe pas
|
||||
*/
|
||||
private async createBucketIfNotExists() {
|
||||
try {
|
||||
const exists = await this.minioClient.bucketExists(this.bucketName);
|
||||
|
||||
if (!exists) {
|
||||
this.logger.log(`📦 Creating bucket: ${this.bucketName}`);
|
||||
|
||||
// Créer le bucket avec la région
|
||||
await this.minioClient.makeBucket(this.bucketName, 'us-east-1');
|
||||
|
||||
// Attendre un peu que le bucket soit créé
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Définir une politique pour rendre les fichiers publics en lecture
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: ['*'] },
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${this.bucketName}/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**await this.minioClient.setBucketPolicy(
|
||||
this.bucketName,
|
||||
JSON.stringify(policy),
|
||||
);*/
|
||||
|
||||
this.logger.log(`✅ Bucket "${this.bucketName}" created and configured`);
|
||||
} else {
|
||||
this.logger.log(`✅ Bucket "${this.bucketName}" already exists`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`❌ Error with bucket: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage(
|
||||
fileBuffer: Buffer,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
userId: string,
|
||||
merchantId: string,
|
||||
merchantName: string,
|
||||
options: UploadOptions = {}
|
||||
): Promise<UploadResult> {
|
||||
|
||||
const {
|
||||
expirySeconds = 3600,
|
||||
} = options;
|
||||
|
||||
this.logger.log(`📤 Starting upload: ${fileName}`);
|
||||
this.logger.log(` User: ${userId}, Merchant: ${merchantName} ID: ${merchantId}`);
|
||||
this.logger.log(` Type: ${contentType}, Size: ${fileBuffer.length} bytes`);
|
||||
|
||||
// 1. Générer une URL pré-signée PUT (la plus simple)
|
||||
const presignedUrl = await this.minioClient.presignedPutObject(
|
||||
this.bucketName,
|
||||
fileName,
|
||||
expirySeconds
|
||||
);
|
||||
|
||||
this.logger.log(` Generated presigned URL: ${presignedUrl.substring(0, 100)}...`);
|
||||
|
||||
// 2. Upload via l'URL pré-signée (simple fetch)
|
||||
const uploadStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await axios.put(presignedUrl, fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': fileBuffer.length.toString(),
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
timeout: 30000, // 30 secondes timeout
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const uploadDuration = Date.now() - uploadStartTime;
|
||||
this.logger.log(` Upload completed in ${uploadDuration}ms (status: ${response.status})`);
|
||||
} else {
|
||||
throw new Error(`Upload failed with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const uploadDuration = Date.now() - uploadStartTime;
|
||||
this.logger.log(` Upload completed in ${uploadDuration}ms`);
|
||||
|
||||
// 3. Attendre un peu et vérifier
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 4. Vérifier que le fichier existe
|
||||
const stat = await this.minioClient.statObject(this.bucketName, fileName);
|
||||
|
||||
// 5. Générer les URLs de téléchargement
|
||||
const [fileUrl, downloadUrl] = await Promise.all([
|
||||
this.minioClient.presignedGetObject(this.bucketName, fileName, expirySeconds),
|
||||
this.minioClient.presignedGetObject(this.bucketName, fileName, 7 * 24 * 3600)
|
||||
]);
|
||||
|
||||
// 6. Générer l'URL publique
|
||||
const publicUrl = await this.getPublicUrl(fileName);
|
||||
|
||||
const result: UploadResult = {
|
||||
success: true,
|
||||
fileName,
|
||||
fileUrl,
|
||||
publicUrl,
|
||||
downloadUrl,
|
||||
size: stat.size,
|
||||
contentType: stat.metaData['content-type'] || contentType,
|
||||
uploadedAt: new Date()
|
||||
};
|
||||
|
||||
this.logger.log(`✅ Upload successful: ${fileName}`);
|
||||
this.logger.log(` Public URL: ${publicUrl}`);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response) {
|
||||
throw new Error(`Upload failed: ${error.response.status} ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
throw new Error('Upload failed: No response received');
|
||||
} else {
|
||||
throw new Error(`Upload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une URL pré-signée temporaire pour téléchargement
|
||||
*/
|
||||
async getFileUrl(fileName: string, expirySeconds = 3600): Promise<string> {
|
||||
try {
|
||||
this.logger.log(`🔗 Generating presigned URL for: ${fileName}`);
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
await this.minioClient.statObject(this.bucketName, fileName);
|
||||
|
||||
const url = await this.minioClient.presignedGetObject(
|
||||
this.bucketName,
|
||||
fileName,
|
||||
expirySeconds,
|
||||
);
|
||||
|
||||
this.logger.log(`✅ URL generated (valid for ${expirySeconds}s)`);
|
||||
return url;
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'NotFound') {
|
||||
this.logger.error(`❌ File not found: ${fileName}`);
|
||||
throw new Error(`File not found: ${fileName}`);
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Error generating URL: ${error.message}`);
|
||||
throw new Error(`Failed to generate URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'URL publique directe (si bucket public)
|
||||
* Vérifie d'abord que le fichier existe
|
||||
*/
|
||||
async getPublicUrl(fileName: string): Promise<string> {
|
||||
try {
|
||||
await this.minioClient.statObject(this.bucketName, fileName);
|
||||
|
||||
const protocol = this.useSSL ? 'https' : 'http';
|
||||
const portSuffix =
|
||||
(this.useSSL && this.port === 443) ||
|
||||
(!this.useSSL && this.port === 80)
|
||||
? ''
|
||||
: `:${this.port}`;
|
||||
|
||||
const url = `${protocol}://${this.endpoint}${portSuffix}/${this.bucketName}/${fileName}`;
|
||||
|
||||
this.logger.log(`🔗 Public URL: ${url}`);
|
||||
return url;
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'NotFound') {
|
||||
this.logger.error(`❌ File not found: ${fileName}`);
|
||||
throw new Error(`File not found: ${fileName}`);
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Error generating public URL: ${error.message}`);
|
||||
throw new Error(`Failed to generate public URL: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un fichier
|
||||
*/
|
||||
async deleteFile(fileName: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`🗑️ Deleting: ${fileName}`);
|
||||
|
||||
// Vérifier que le fichier existe
|
||||
await this.minioClient.statObject(this.bucketName, fileName);
|
||||
|
||||
await this.minioClient.removeObject(this.bucketName, fileName);
|
||||
|
||||
this.logger.log(`✅ Deleted: ${fileName}`);
|
||||
|
||||
} catch (error) {
|
||||
if (error.code === 'NotFound') {
|
||||
this.logger.warn(`⚠️ File not found (already deleted?): ${fileName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Delete failed: ${error.message}`, error.stack);
|
||||
throw new Error(`Failed to delete file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les fichiers d'un utilisateur
|
||||
*/
|
||||
async listUserFiles(userId: string): Promise<string[]> {
|
||||
try {
|
||||
this.logger.log(`📂 Listing files for user: ${userId}`);
|
||||
|
||||
const stream = this.minioClient.listObjects(
|
||||
this.bucketName,
|
||||
`${userId}/`,
|
||||
true,
|
||||
);
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (obj) => {
|
||||
if (obj.name) {
|
||||
files.push(obj.name);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
this.logger.error(`❌ List failed: ${err.message}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
this.logger.log(`✅ Found ${files.length} file(s) for user ${userId}`);
|
||||
resolve(files);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ List error: ${error.message}`);
|
||||
throw new Error(`Failed to list files: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un fichier existe
|
||||
*/
|
||||
async fileExists(fileName: string): Promise<boolean> {
|
||||
try {
|
||||
await this.minioClient.statObject(this.bucketName, fileName);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'NotFound') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les infos d'un fichier
|
||||
*/
|
||||
async getFileInfo(fileName: string): Promise<any> {
|
||||
try {
|
||||
const stat = await this.minioClient.statObject(this.bucketName, fileName);
|
||||
return {
|
||||
size: stat.size,
|
||||
lastModified: stat.lastModified,
|
||||
etag: stat.etag,
|
||||
contentType: stat.metaData['content-type'],
|
||||
metaData: stat.metaData,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Failed to get file info: ${error.message}`);
|
||||
throw new Error(`File info error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user