Compare commits
11 Commits
92781b69e5
...
2cfce5ef99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cfce5ef99 | ||
|
|
333782553a | ||
|
|
bdbbaeaa65 | ||
|
|
5e5ecb6cd1 | ||
|
|
33a9dbde36 | ||
|
|
4ceba378f0 | ||
|
|
d3fb5b7867 | ||
|
|
fcfb1af958 | ||
|
|
f97a730a19 | ||
|
|
61b3746b2c | ||
|
|
4e359efd5e |
2210
package-lock.json
generated
2210
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,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 {}
|
||||
|
||||
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,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()
|
||||
|
||||
@ -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 })
|
||||
130
src/merchant/controllers/service.controller.ts
Normal file
130
src/merchant/controllers/service.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
src/merchant/dto/create.plan.dto.ts
Normal file
64
src/merchant/dto/create.plan.dto.ts
Normal 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;
|
||||
}
|
||||
28
src/merchant/dto/create.service.dto.ts
Normal file
28
src/merchant/dto/create.service.dto.ts
Normal 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;
|
||||
}
|
||||
4
src/merchant/dto/update.plan.dto.ts
Normal file
4
src/merchant/dto/update.plan.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePlanDto } from './create.plan.dto';
|
||||
|
||||
export class UpdatePlanDto extends PartialType(CreatePlanDto) {}
|
||||
7
src/merchant/dto/update.service.dto.ts
Normal file
7
src/merchant/dto/update.service.dto.ts
Normal 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),
|
||||
) {}
|
||||
42
src/merchant/entities/service.entity.ts
Normal file
42
src/merchant/entities/service.entity.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
],
|
||||
|
||||
@ -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,
|
||||
|
||||
267
src/merchant/services/service.service.ts
Normal file
267
src/merchant/services/service.service.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
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