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": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/bull": "^11.0.4",
|
"@nestjs/bull": "^11.0.4",
|
||||||
"@nestjs/cache-manager": "^3.0.1",
|
"@nestjs/cache-manager": "^3.1.0",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.1.11",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.1.11",
|
||||||
"@nestjs/event-emitter": "^3.0.1",
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/jwt": "^11.0.1",
|
"@nestjs/jwt": "^11.0.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.1.11",
|
||||||
"@nestjs/schedule": "^6.0.1",
|
"@nestjs/schedule": "^6.0.1",
|
||||||
"@nestjs/swagger": "^11.2.1",
|
"@nestjs/swagger": "^11.2.1",
|
||||||
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@prisma/client": "^6.17.1",
|
"@prisma/client": "^6.17.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"cache-manager": "^7.2.8",
|
||||||
"cache-manager-redis-store": "^3.0.1",
|
"cache-manager-redis-store": "^3.0.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"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-headerapikey": "^1.2.2",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@ -50,6 +56,7 @@
|
|||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|||||||
@ -6,20 +6,73 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
|||||||
import { CacheModule } from '@nestjs/cache-manager';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
import * as redisStore from 'cache-manager-redis-store';
|
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 configurations
|
||||||
|
|
||||||
// Import des modules
|
// Import des modules
|
||||||
import { PrismaService } from './shared/services/prisma.service';
|
import { PrismaService } from './shared/services/prisma.service';
|
||||||
import { MerchantModule } from './merchant/merchant.module';
|
import { MerchantModule } from './merchant/merchant.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
// Configuration Module
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
//load: [],
|
load: [keycloakConfig],
|
||||||
|
validationSchema: keycloakConfigValidationSchema,
|
||||||
|
validationOptions: {
|
||||||
|
allowUnknown: false,
|
||||||
|
abortEarly: true,
|
||||||
|
},
|
||||||
envFilePath: ['.env.local', '.env'],
|
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({
|
CacheModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: (configService: ConfigService) => ({
|
useFactory: (configService: ConfigService) => ({
|
||||||
@ -33,9 +86,27 @@ import { MerchantModule } from './merchant/merchant.module';
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
}),
|
}),
|
||||||
|
HealthModule,
|
||||||
MerchantModule
|
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],
|
exports: [PrismaService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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
|
// CORS
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: process.env.CORS_ORIGINS?.split(',') || '*',
|
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Swagger
|
// Swagger
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('Payment Hub API')
|
.setTitle('Merchant Config API')
|
||||||
.setDescription('Unified DCB Payment Aggregation Platform')
|
.setDescription('Unified DCB Payment Aggregation Platform')
|
||||||
.setVersion('1.0.0')
|
.setVersion('1.0.0')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
|
|||||||
@ -10,12 +10,14 @@ import {
|
|||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
DefaultValuePipe,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { MerchantService } from './services/merchant.service';
|
import { MerchantService } from '../services/merchant.service';
|
||||||
import { CreateMerchantPartnerDto } from './dto/create.merchant.dto';
|
import { CreateMerchantPartnerDto } from '../dto/create.merchant.dto';
|
||||||
import { UpdateMerchantPartnerDto } from './dto/ update.merchant.dto';
|
import { UpdateMerchantPartnerDto } from '../dto/ update.merchant.dto';
|
||||||
import { AddUserToMerchantDto, UpdateUserRoleDto } from './dto/merchant.user.dto';
|
import { AddUserToMerchantDto, UpdateUserRoleDto } from '../dto/merchant.user.dto';
|
||||||
|
|
||||||
@ApiTags('merchants')
|
@ApiTags('merchants')
|
||||||
@Controller('merchants')
|
@Controller('merchants')
|
||||||
@ -32,19 +34,23 @@ export class MerchantController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Get all merchants' })
|
@ApiOperation({ summary: 'Get all merchants' })
|
||||||
@ApiQuery({ name: 'skip', required: false, type: Number })
|
@ApiQuery({ name: 'skip', required: false, type: Number, example: 0 })
|
||||||
@ApiQuery({ name: 'take', required: false, type: Number })
|
@ApiQuery({ name: 'take', required: false, type: Number, example: 10 })
|
||||||
@ApiResponse({ status: 200, description: 'List of merchants' })
|
@ApiResponse({ status: 200, description: 'Paginated merchants' })
|
||||||
findAll(
|
async findAll(
|
||||||
@Query('skip') skip?: string,
|
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip: number,
|
||||||
@Query('take') take?: string,
|
@Query('take', new DefaultValuePipe(10), ParseIntPipe) take: number,
|
||||||
) {
|
) {
|
||||||
return this.merchantService.findAll(
|
|
||||||
skip ? parseInt(skip) : 0,
|
if (skip < 0) throw new BadRequestException('skip must be >= 0');
|
||||||
take ? parseInt(take) : 10,
|
if (take < 1 || take > 100) {
|
||||||
);
|
throw new BadRequestException('take must be between 1 and 100');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.merchantService.findAll(skip, take);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get merchant by ID' })
|
@ApiOperation({ summary: 'Get merchant by ID' })
|
||||||
@ApiParam({ name: 'id', type: Number })
|
@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 { HttpModule } from '@nestjs/axios';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
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 { HttpUserServiceClient } from './services/user.service.client';
|
||||||
import { PrismaService } from 'src/shared/services/prisma.service';
|
import { PrismaService } from 'src/shared/services/prisma.service';
|
||||||
import { MerchantService } from './services/merchant.service';
|
import { MerchantService } from './services/merchant.service';
|
||||||
|
import { ServiceController } from './controllers/service.controller';
|
||||||
|
import { ServiceManagementService } from './services/service.service';
|
||||||
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -15,9 +17,10 @@ import { MerchantService } from './services/merchant.service';
|
|||||||
ConfigModule,
|
ConfigModule,
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [MerchantController],
|
controllers: [MerchantController,ServiceController],
|
||||||
providers: [
|
providers: [
|
||||||
MerchantService,
|
MerchantService,
|
||||||
|
ServiceManagementService,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
HttpUserServiceClient
|
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 { MerchantPartnerWithRelations, MerchantUserWithInfo } from '../entities/merchant.entity';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import type { UserServiceClient } from '../interfaces/user.service.interface';
|
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[]> {
|
async findAll(skip = 0, take = 10): Promise<{ items: MerchantPartnerWithRelations[], total: number }> {
|
||||||
const merchants = await this.prisma.merchantPartner.findMany({
|
if (skip < 0) throw new BadRequestException('skip must be >= 0');
|
||||||
skip,
|
if (take < 1 || take > 100) throw new BadRequestException('take must be between 1 et 100');
|
||||||
take,
|
|
||||||
include: {
|
|
||||||
configs: true,
|
|
||||||
merchantUsers: true,
|
|
||||||
technicalContacts: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
* Add user to merchant
|
||||||
*/
|
*/
|
||||||
async addUserToMerchant(dto: AddUserToMerchantDto): Promise<MerchantUserWithInfo> {
|
async addUserToMerchant(dto: AddUserToMerchantDto): Promise<MerchantUserWithInfo> {
|
||||||
// Check if merchant exists
|
console.log('🔹 Starting addUserToMerchant process', dto);
|
||||||
await this.findOne(dto.merchantPartnerId);
|
|
||||||
|
|
||||||
// Validate user exists
|
// 1️⃣ Vérifier que le merchant existe
|
||||||
const userExists = await this.userServiceClient.verifyUserExists(dto.userId);
|
let merchant;
|
||||||
if (!userExists) {
|
try {
|
||||||
throw new BadRequestException(`User with ID ${dto.userId} not found in user service`);
|
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
|
// 2️⃣ Vérifier que l'utilisateur existe dans le service utilisateur
|
||||||
const existing = await this.prisma.merchantUser.findUnique({
|
let userExists: boolean;
|
||||||
where: {
|
try {
|
||||||
userId_merchantPartnerId: {
|
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,
|
userId: dto.userId,
|
||||||
|
role: dto.role,
|
||||||
merchantPartnerId: dto.merchantPartnerId,
|
merchantPartnerId: dto.merchantPartnerId,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
console.log(`✅ MerchantUser created: ID=${merchantUser.id}, UserID=${dto.userId}, MerchantID=${dto.merchantPartnerId}`);
|
||||||
|
} catch (err) {
|
||||||
if (existing) {
|
console.error(`❌ Error creating merchantUser`, err);
|
||||||
throw new ConflictException(`User ${dto.userId} is already attached to merchant ${dto.merchantPartnerId}`);
|
throw new InternalServerErrorException('Failed to create merchant user');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create merchant user
|
// 5️⃣ Émettre l'événement
|
||||||
const merchantUser = await this.prisma.merchantUser.create({
|
try {
|
||||||
data: {
|
this.eventEmitter.emit('merchant.user.attached', {
|
||||||
|
merchantId: dto.merchantPartnerId,
|
||||||
userId: dto.userId,
|
userId: dto.userId,
|
||||||
role: dto.role,
|
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
|
// 6️⃣ Récupérer les infos utilisateur enrichies
|
||||||
this.eventEmitter.emit('merchant.user.attached', {
|
let userInfo;
|
||||||
merchantId: dto.merchantPartnerId,
|
try {
|
||||||
userId: dto.userId,
|
userInfo = await this.userServiceClient.getUserInfo(dto.userId);
|
||||||
role: dto.role,
|
console.log(`📌 User info fetched for ID=${dto.userId}`);
|
||||||
timestamp: new Date(),
|
} catch (err) {
|
||||||
});
|
console.error(`❌ Failed to fetch user info, returning merchantUser without enrichment`, err);
|
||||||
|
userInfo = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich with user info
|
// 7️⃣ Retourner l'objet final
|
||||||
const userInfo = await this.userServiceClient.getUserInfo(dto.userId);
|
|
||||||
return {
|
return {
|
||||||
...merchantUser,
|
...merchantUser,
|
||||||
userInfo,
|
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 { ConfigService } from '@nestjs/config';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { UserInfo, UserServiceClient } from '../interfaces/user.service.interface';
|
import { UserInfo, UserServiceClient } from '../interfaces/user.service.interface';
|
||||||
|
import { KeycloakConfig } from 'src/config/keycloak.config';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpUserServiceClient implements UserServiceClient {
|
export class HttpUserServiceClient implements UserServiceClient {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly keycloakUrl: string;
|
private readonly keycloakConfig: KeycloakConfig;
|
||||||
private readonly keycloakRealm: string;
|
|
||||||
private readonly clientId: string;
|
|
||||||
private readonly clientSecret: string;
|
|
||||||
|
|
||||||
private accessToken: string | null = null;
|
private accessToken: string | null = null;
|
||||||
private tokenExpiry: number = 0;
|
private tokenExpiry: number = 0;
|
||||||
@ -19,21 +17,17 @@ export class HttpUserServiceClient implements UserServiceClient {
|
|||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
|
this.keycloakConfig = this.getKeycloakConfig();
|
||||||
this.baseUrl = this.configService.get<string>('USER_SERVICE') || 'http://localhost:3001';
|
this.baseUrl = this.configService.get<string>('USER_SERVICE') || 'http://localhost:3001';
|
||||||
|
}
|
||||||
|
|
||||||
const keycloakUrl = this.configService.get<string>('KEYCLOAK_SERVER_URL');
|
// === CONFIGURATION ===
|
||||||
const keycloakRealm = this.configService.get<string>('KEYCLOAK_REALM');
|
private getKeycloakConfig(): KeycloakConfig {
|
||||||
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID');
|
const config = this.configService.get<KeycloakConfig>('keycloak');
|
||||||
const clientSecret = this.configService.get<string>('KEYCLOAK_CLIENT_SECRET');
|
if (!config) {
|
||||||
|
throw new Error('Keycloak configuration not found');
|
||||||
if (!keycloakUrl || !keycloakRealm || !clientId || !clientSecret) {
|
|
||||||
throw new Error('Missing required Keycloak configuration');
|
|
||||||
}
|
}
|
||||||
|
return config;
|
||||||
this.keycloakUrl = keycloakUrl;
|
|
||||||
this.keycloakRealm = keycloakRealm;
|
|
||||||
this.clientId = clientId;
|
|
||||||
this.clientSecret = clientSecret;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAccessToken(): Promise<string> {
|
private async getAccessToken(): Promise<string> {
|
||||||
@ -43,12 +37,12 @@ export class HttpUserServiceClient implements UserServiceClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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();
|
const params = new URLSearchParams();
|
||||||
params.append('grant_type', 'client_credentials');
|
params.append('grant_type', 'client_credentials');
|
||||||
params.append('client_id', this.clientId);
|
params.append('client_id', this.keycloakConfig.authClientId);
|
||||||
params.append('client_secret', this.clientSecret);
|
params.append('client_secret', this.keycloakConfig.authClientSecret);
|
||||||
|
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.httpService.post(tokenUrl, params.toString(), {
|
this.httpService.post(tokenUrl, params.toString(), {
|
||||||
@ -81,22 +75,64 @@ export class HttpUserServiceClient implements UserServiceClient {
|
|||||||
|
|
||||||
async verifyUserExists(userId: string): Promise<boolean> {
|
async verifyUserExists(userId: string): Promise<boolean> {
|
||||||
try {
|
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 headers = await this.getAuthHeaders();
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}/merchant-users/${userId}`;
|
||||||
|
|
||||||
const response = await firstValueFrom(
|
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) {
|
console.log(`✅ [verifyUserExists] Réponse complète:`, JSON.stringify(response.data, null, 2));
|
||||||
if (error.response?.status === 404) {
|
|
||||||
|
// 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;
|
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) {
|
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;
|
this.accessToken = null;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
return this.verifyUserExists(userId);
|
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(
|
throw new HttpException(
|
||||||
'Failed to verify user existence',
|
`Failed to verify user existence: ${error.message}`,
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -106,7 +142,7 @@ export class HttpUserServiceClient implements UserServiceClient {
|
|||||||
try {
|
try {
|
||||||
const headers = await this.getAuthHeaders();
|
const headers = await this.getAuthHeaders();
|
||||||
const response = await firstValueFrom(
|
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);
|
return this.mapToUserInfo(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -132,7 +168,7 @@ export class HttpUserServiceClient implements UserServiceClient {
|
|||||||
try {
|
try {
|
||||||
const headers = await this.getAuthHeaders();
|
const headers = await this.getAuthHeaders();
|
||||||
const response = await firstValueFrom(
|
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));
|
return response.data.map(user => this.mapToUserInfo(user));
|
||||||
} catch (error) {
|
} 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