Compare commits

...

11 Commits

Author SHA1 Message Date
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
2cfce5ef99 Merge branch 'develop' of https://github.com/Cameleonapp/dcb-service-merchant-config into develop 2026-02-03 17:42:06 +00:00
diallolatoile
333782553a feat: Add Health Check Endpoint 2026-01-17 11:36:41 +00:00
diallolatoile
bdbbaeaa65 feat: Manage Images using Minio Service 2026-01-13 04:00:22 +00:00
diallolatoile
5e5ecb6cd1 feat: Manage Images using Minio Service 2026-01-13 03:49:19 +00:00
diallolatoile
33a9dbde36 feat: Manage Images using Minio Service 2026-01-11 19:54:18 +00:00
diallolatoile
4ceba378f0 feat: Manage Images using Minio Service 2026-01-11 04:14:50 +00:00
diallolatoile
d3fb5b7867 feat: Correct User Exists Validation 2026-01-07 22:52:01 +00:00
diallolatoile
fcfb1af958 feat: Correct User Exists Validation 2026-01-07 22:15:18 +00:00
diallolatoile
f97a730a19 feat: Correct User Exists Validation 2026-01-07 00:00:54 +00:00
diallolatoile
61b3746b2c feat: Correct User Exists Validation 2026-01-06 23:52:54 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
4e359efd5e gestion des services 2025-11-13 23:58:18 +00:00
23 changed files with 3548 additions and 758 deletions

2210
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -6,20 +6,73 @@ 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],
useFactory: (configService: ConfigService) => ({
@ -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 {}

View 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);

View 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;
}
}

View 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 {}

View 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}`);
}
}
}

View 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 {}

View 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);
}
}

View File

@ -20,13 +20,13 @@ async function bootstrap() {
// CORS
app.enableCors({
origin: process.env.CORS_ORIGINS?.split(',') || '*',
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
credentials: true,
});
// Swagger
const config = new DocumentBuilder()
.setTitle('Payment Hub API')
.setTitle('Merchant Config API')
.setDescription('Unified DCB Payment Aggregation Platform')
.setVersion('1.0.0')
.addBearerAuth()

View File

@ -10,12 +10,14 @@ import {
ParseIntPipe,
HttpCode,
HttpStatus,
DefaultValuePipe,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { MerchantService } from './services/merchant.service';
import { CreateMerchantPartnerDto } from './dto/create.merchant.dto';
import { UpdateMerchantPartnerDto } from './dto/ update.merchant.dto';
import { AddUserToMerchantDto, UpdateUserRoleDto } from './dto/merchant.user.dto';
import { MerchantService } from '../services/merchant.service';
import { CreateMerchantPartnerDto } from '../dto/create.merchant.dto';
import { UpdateMerchantPartnerDto } from '../dto/ update.merchant.dto';
import { AddUserToMerchantDto, UpdateUserRoleDto } from '../dto/merchant.user.dto';
@ApiTags('merchants')
@Controller('merchants')
@ -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 })

View File

@ -0,0 +1,130 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { ServiceManagementService } from '../services/service.service';
import { CreateServiceDto } from '../dto/create.service.dto';
import { UpdateServiceDto } from '../dto/update.service.dto';
import { CreatePlanDto } from '../dto/create.plan.dto';
import { UpdatePlanDto } from '../dto/update.plan.dto';
@ApiTags('services')
@Controller('services')
export class ServiceController {
constructor(private readonly serviceManagementService: ServiceManagementService) {}
// ==================== SERVICE ENDPOINTS ====================
@Post()
@ApiOperation({ summary: 'Create a new service for a merchant' })
@ApiResponse({ status: 201, description: 'Service created successfully' })
@ApiResponse({ status: 400, description: 'Bad request - validation failed' })
@ApiResponse({ status: 404, description: 'Merchant not found' })
create(@Body() createServiceDto: CreateServiceDto) {
return this.serviceManagementService.createService(createServiceDto);
}
@Get('merchant/:merchantId')
@ApiOperation({ summary: 'Get all services for a merchant' })
@ApiParam({ name: 'merchantId', type: Number })
@ApiResponse({ status: 200, description: 'List of merchant services' })
@ApiResponse({ status: 404, description: 'Merchant not found' })
findAllByMerchant(@Param('merchantId', ParseIntPipe) merchantId: number) {
return this.serviceManagementService.findAllByMerchant(merchantId);
}
@Get(':id')
@ApiOperation({ summary: 'Get service by ID' })
@ApiParam({ name: 'id', type: Number })
@ApiResponse({ status: 200, description: 'Service found' })
@ApiResponse({ status: 404, description: 'Service not found' })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.serviceManagementService.findOneService(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update service' })
@ApiParam({ name: 'id', type: Number })
@ApiResponse({ status: 200, description: 'Service updated successfully' })
@ApiResponse({ status: 404, description: 'Service not found' })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateServiceDto: UpdateServiceDto,
) {
return this.serviceManagementService.updateService(id, updateServiceDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete service' })
@ApiParam({ name: 'id', type: Number })
@ApiResponse({ status: 204, description: 'Service deleted successfully' })
@ApiResponse({ status: 404, description: 'Service not found' })
remove(@Param('id', ParseIntPipe) id: number) {
return this.serviceManagementService.removeService(id);
}
// ==================== PLAN ENDPOINTS ====================
@Post(':serviceId/plans')
@ApiOperation({ summary: 'Create a new plan for a service' })
@ApiParam({ name: 'serviceId', type: Number })
@ApiResponse({ status: 201, description: 'Plan created successfully' })
@ApiResponse({ status: 400, description: 'Bad request - validation failed' })
@ApiResponse({ status: 404, description: 'Service not found' })
createPlan(
@Param('serviceId', ParseIntPipe) serviceId: number,
@Body() createPlanDto: CreatePlanDto,
) {
return this.serviceManagementService.createPlan(serviceId, createPlanDto);
}
@Get(':serviceId/plans')
@ApiOperation({ summary: 'Get all plans for a service' })
@ApiParam({ name: 'serviceId', type: Number })
@ApiResponse({ status: 200, description: 'List of service plans' })
@ApiResponse({ status: 404, description: 'Service not found' })
findAllPlans(@Param('serviceId', ParseIntPipe) serviceId: number) {
return this.serviceManagementService.findAllPlansByService(serviceId);
}
@Get('plans/:planId')
@ApiOperation({ summary: 'Get plan by ID' })
@ApiParam({ name: 'planId', type: Number })
@ApiResponse({ status: 200, description: 'Plan found' })
@ApiResponse({ status: 404, description: 'Plan not found' })
findOnePlan(@Param('planId', ParseIntPipe) planId: number) {
return this.serviceManagementService.findOnePlan(planId);
}
@Patch('plans/:planId')
@ApiOperation({ summary: 'Update plan' })
@ApiParam({ name: 'planId', type: Number })
@ApiResponse({ status: 200, description: 'Plan updated successfully' })
@ApiResponse({ status: 404, description: 'Plan not found' })
updatePlan(
@Param('planId', ParseIntPipe) planId: number,
@Body() updatePlanDto: UpdatePlanDto,
) {
return this.serviceManagementService.updatePlan(planId, updatePlanDto);
}
@Delete('plans/:planId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete plan' })
@ApiParam({ name: 'planId', type: Number })
@ApiResponse({ status: 204, description: 'Plan deleted successfully' })
@ApiResponse({ status: 404, description: 'Plan not found' })
removePlan(@Param('planId', ParseIntPipe) planId: number) {
return this.serviceManagementService.removePlan(planId);
}
}

View File

@ -0,0 +1,64 @@
import {
IsString,
IsNotEmpty,
IsEnum,
IsNumber,
Min,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Periodicity, Currency } from "generated/prisma";
export class CreatePlanDto {
@ApiProperty({
description: 'Plan name',
example: 'Monthly Premium',
})
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({
description: 'Plan type',
enum: Periodicity,
example: 'Monthly',
})
@IsEnum(Periodicity)
@IsNotEmpty()
type: Periodicity;
@ApiProperty({
description: 'Plan amount',
example: 5000,
})
@IsNumber()
@Min(0)
@IsNotEmpty()
amount: number;
@ApiProperty({
description: 'Tax amount',
example: 900,
})
@IsNumber()
@Min(0)
@IsNotEmpty()
tax: number;
@ApiProperty({
description: 'Currency',
enum: Currency,
example: 'XOF',
})
@IsEnum(Currency)
@IsNotEmpty()
currency: Currency;
@ApiProperty({
description: 'Billing periodicity',
enum: Periodicity,
example: 'Monthly',
})
@IsEnum(Periodicity)
@IsNotEmpty()
periodicity: Periodicity;
}

View File

@ -0,0 +1,28 @@
import { IsString, IsNotEmpty, IsOptional, IsInt } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateServiceDto {
@ApiProperty({
description: 'Service name',
example: 'Premium Streaming',
})
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({
description: 'Service description',
example: 'Access to premium content streaming',
})
@IsString()
@IsOptional()
description?: string;
@ApiProperty({
description: 'Merchant partner ID',
example: 1,
})
@IsInt()
@IsNotEmpty()
merchantPartnerId: number;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePlanDto } from './create.plan.dto';
export class UpdatePlanDto extends PartialType(CreatePlanDto) {}

View File

@ -0,0 +1,7 @@
import { PartialType } from '@nestjs/swagger';
import { CreateServiceDto } from './create.service.dto';
import { OmitType } from '@nestjs/swagger';
export class UpdateServiceDto extends PartialType(
OmitType(CreateServiceDto, ['merchantPartnerId'] as const),
) {}

View File

@ -0,0 +1,42 @@
import { Service, Plan, MerchantPartner, Periodicity, Currency } from "generated/prisma";
export type ServiceEntity = Service;
export type PlanEntity = Plan & {
service?: Service;
};
export type ServiceWithPlans = Service & {
plans: Plan[];
merchantPartner?: MerchantPartner;
};
export interface CreateServiceData {
name: string;
description?: string;
merchantPartnerId: number;
}
export interface UpdateServiceData {
name?: string;
description?: string;
}
export interface CreatePlanData {
name: string;
type: Periodicity;
amount: number;
tax: number;
currency: Currency;
periodicity: Periodicity;
serviceId: number;
}
export interface UpdatePlanData {
name?: string;
type?: Periodicity;
amount?: number;
tax?: number;
currency?: Currency;
periodicity?: Periodicity;
}

View File

@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { MerchantController } from './merchant.controller';
import { MerchantController } from './controllers/merchant.controller';
import { HttpUserServiceClient } from './services/user.service.client';
import { PrismaService } from 'src/shared/services/prisma.service';
import { MerchantService } from './services/merchant.service';
import { ServiceController } from './controllers/service.controller';
import { ServiceManagementService } from './services/service.service';
@Module({
@ -15,9 +17,10 @@ import { MerchantService } from './services/merchant.service';
ConfigModule,
EventEmitterModule.forRoot(),
],
controllers: [MerchantController],
controllers: [MerchantController,ServiceController],
providers: [
MerchantService,
ServiceManagementService,
PrismaService,
HttpUserServiceClient
],

View File

@ -1,4 +1,4 @@
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';
@ -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,

View File

@ -0,0 +1,267 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/shared/services/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CreateServiceDto } from '../dto/create.service.dto';
import { UpdateServiceDto } from '../dto/update.service.dto';
import { CreatePlanDto } from '../dto/create.plan.dto';
import { UpdatePlanDto } from '../dto/update.plan.dto';
import { ServiceWithPlans, PlanEntity } from '../entities/service.entity';
@Injectable()
export class ServiceManagementService {
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
// ==================== SERVICE METHODS ====================
/**
* Create a new service for a merchant
*/
async createService(dto: CreateServiceDto): Promise<ServiceWithPlans> {
// Check if merchant exists
const merchant = await this.prisma.merchantPartner.findUnique({
where: { id: dto.merchantPartnerId },
});
if (!merchant) {
throw new NotFoundException(
`Merchant with ID ${dto.merchantPartnerId} not found`,
);
}
const service = await this.prisma.service.create({
data: {
name: dto.name,
description: dto.description,
merchantPartnerId: dto.merchantPartnerId,
},
include: {
plans: true,
merchantPartner: true,
},
});
this.eventEmitter.emit('service.created', {
serviceId: service.id,
serviceName: service.name,
merchantId: dto.merchantPartnerId,
timestamp: new Date(),
});
return service;
}
/**
* Find all services for a merchant
*/
async findAllByMerchant(merchantId: number): Promise<ServiceWithPlans[]> {
// Check if merchant exists
const merchant = await this.prisma.merchantPartner.findUnique({
where: { id: merchantId },
});
if (!merchant) {
throw new NotFoundException(`Merchant with ID ${merchantId} not found`);
}
return this.prisma.service.findMany({
where: { merchantPartnerId: merchantId },
include: {
plans: true,
merchantPartner: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
/**
* Find service by ID
*/
async findOneService(id: number): Promise<ServiceWithPlans> {
const service = await this.prisma.service.findUnique({
where: { id },
include: {
plans: true,
merchantPartner: true,
},
});
if (!service) {
throw new NotFoundException(`Service with ID ${id} not found`);
}
return service;
}
/**
* Update service
*/
async updateService(
id: number,
dto: UpdateServiceDto,
): Promise<ServiceWithPlans> {
await this.findOneService(id); // Check if exists
const service = await this.prisma.service.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
},
include: {
plans: true,
merchantPartner: true,
},
});
this.eventEmitter.emit('service.updated', {
serviceId: id,
serviceName: service.name,
timestamp: new Date(),
});
return service;
}
/**
* Delete service
*/
async removeService(id: number): Promise<void> {
await this.findOneService(id); // Check if exists
await this.prisma.service.delete({
where: { id },
});
this.eventEmitter.emit('service.deleted', {
serviceId: id,
timestamp: new Date(),
});
}
// ==================== PLAN METHODS ====================
/**
* Create a new plan for a service
*/
async createPlan(
serviceId: number,
dto: CreatePlanDto,
): Promise<PlanEntity> {
// Check if service exists
await this.findOneService(serviceId);
const plan = await this.prisma.plan.create({
data: {
name: dto.name,
type: dto.type,
amount: dto.amount,
tax: dto.tax,
currency: dto.currency,
periodicity: dto.periodicity,
serviceId,
},
include: {
service: true,
},
});
this.eventEmitter.emit('plan.created', {
planId: plan.id,
planName: plan.name,
serviceId,
amount: plan.amount,
currency: plan.currency,
timestamp: new Date(),
});
return plan;
}
/**
* Find all plans for a service
*/
async findAllPlansByService(serviceId: number): Promise<PlanEntity[]> {
// Check if service exists
await this.findOneService(serviceId);
return this.prisma.plan.findMany({
where: { serviceId },
include: {
service: true,
},
orderBy: {
amount: 'asc',
},
});
}
/**
* Find plan by ID
*/
async findOnePlan(id: number): Promise<PlanEntity> {
const plan = await this.prisma.plan.findUnique({
where: { id },
include: {
service: true,
},
});
if (!plan) {
throw new NotFoundException(`Plan with ID ${id} not found`);
}
return plan;
}
/**
* Update plan
*/
async updatePlan(id: number, dto: UpdatePlanDto): Promise<PlanEntity> {
await this.findOnePlan(id); // Check if exists
const plan = await this.prisma.plan.update({
where: { id },
data: {
name: dto.name,
type: dto.type,
amount: dto.amount,
tax: dto.tax,
currency: dto.currency,
periodicity: dto.periodicity,
},
include: {
service: true,
},
});
this.eventEmitter.emit('plan.updated', {
planId: id,
planName: plan.name,
amount: plan.amount,
timestamp: new Date(),
});
return plan;
}
/**
* Delete plan
*/
async removePlan(id: number): Promise<void> {
await this.findOnePlan(id); // Check if exists
await this.prisma.plan.delete({
where: { id },
});
this.eventEmitter.emit('plan.deleted', {
planId: id,
timestamp: new Date(),
});
}
}

View File

@ -3,14 +3,12 @@ import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
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 keycloakUrl: string;
private readonly keycloakRealm: string;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly keycloakConfig: KeycloakConfig;
private accessToken: string | null = null;
private tokenExpiry: number = 0;
@ -19,21 +17,17 @@ export class HttpUserServiceClient implements UserServiceClient {
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {
this.keycloakConfig = this.getKeycloakConfig();
this.baseUrl = this.configService.get<string>('USER_SERVICE') || 'http://localhost:3001';
}
const keycloakUrl = this.configService.get<string>('KEYCLOAK_SERVER_URL');
const keycloakRealm = this.configService.get<string>('KEYCLOAK_REALM');
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID');
const clientSecret = this.configService.get<string>('KEYCLOAK_CLIENT_SECRET');
if (!keycloakUrl || !keycloakRealm || !clientId || !clientSecret) {
throw new Error('Missing required Keycloak configuration');
// === CONFIGURATION ===
private getKeycloakConfig(): KeycloakConfig {
const config = this.configService.get<KeycloakConfig>('keycloak');
if (!config) {
throw new Error('Keycloak configuration not found');
}
this.keycloakUrl = keycloakUrl;
this.keycloakRealm = keycloakRealm;
this.clientId = clientId;
this.clientSecret = clientSecret;
return config;
}
private async getAccessToken(): Promise<string> {
@ -43,12 +37,12 @@ export class HttpUserServiceClient implements UserServiceClient {
}
try {
const tokenUrl = `${this.keycloakUrl}/realms/${this.keycloakRealm}/protocol/openid-connect/token`;
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.clientId);
params.append('client_secret', this.clientSecret);
params.append('client_id', this.keycloakConfig.authClientId);
params.append('client_secret', this.keycloakConfig.authClientSecret);
const response = await firstValueFrom(
this.httpService.post(tokenUrl, params.toString(), {
@ -81,22 +75,64 @@ export class HttpUserServiceClient implements UserServiceClient {
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`, { headers }),
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) {
// Token invalide, réessayer une fois après rafraîchissement
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,
);
}
@ -106,7 +142,7 @@ export class HttpUserServiceClient implements UserServiceClient {
try {
const headers = await this.getAuthHeaders();
const response = await firstValueFrom(
this.httpService.get(`${this.baseUrl}/users/${userId}`, { headers }),
this.httpService.get(`${this.baseUrl}/merchant-users/${userId}`, { headers }),
);
return this.mapToUserInfo(response.data);
} catch (error) {
@ -132,7 +168,7 @@ export class HttpUserServiceClient implements UserServiceClient {
try {
const headers = await this.getAuthHeaders();
const response = await firstValueFrom(
this.httpService.post(`${this.baseUrl}/users/batch`, { userIds }, { headers }),
this.httpService.post(`${this.baseUrl}/merchant-users/batch`, { userIds }, { headers }),
);
return response.data.map(user => this.mapToUserInfo(user));
} catch (error) {

View 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 {}

View 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}`);
}
}
}