fix error

This commit is contained in:
Mamadou Khoussa [028918 DSI/DAC/DIF/DS] 2025-10-24 23:34:57 +00:00
parent 3a0e3c466a
commit 3eae8d2805
7 changed files with 73 additions and 393 deletions

View File

@ -19,6 +19,7 @@ import { OperatorsModule } from './modules/operators/operators.module';
import { PaymentsModule } from './modules/payments/payments.module'; import { PaymentsModule } from './modules/payments/payments.module';
import { SubscriptionsModule } from './modules/subscriptions/subscriptions.module'; import { SubscriptionsModule } from './modules/subscriptions/subscriptions.module';
import { NotificationsModule } from './modules/notifications/notifications.module'; import { NotificationsModule } from './modules/notifications/notifications.module';
import { OtpChallengeModule } from './modules/challenge/otp.challenge.module';
@Module({ @Module({
imports: [ imports: [
@ -56,6 +57,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
PaymentsModule, PaymentsModule,
SubscriptionsModule, SubscriptionsModule,
NotificationsModule, NotificationsModule,
OtpChallengeModule
], ],
providers: [PrismaService], providers: [PrismaService],
exports: [PrismaService], exports: [PrismaService],

View File

@ -134,7 +134,6 @@ export class OrangeAdapter {
const response: OtpChallengeResponseDto = { const response: OtpChallengeResponseDto = {
challengeId: orangeResponse.challengeId || '', challengeId: orangeResponse.challengeId || '',
merchantId: request.merchantId, merchantId: request.merchantId,
transactionId: request.transactionId,
status: this.mapOrangeStatus(orangeResponse), status: this.mapOrangeStatus(orangeResponse),
message: orangeResponse.message, message: orangeResponse.message,
expiresIn: orangeResponse.expiresIn, expiresIn: orangeResponse.expiresIn,
@ -220,7 +219,6 @@ export class OrangeAdapter {
return { return {
challengeId: '', challengeId: '',
merchantId: request.merchantId, merchantId: request.merchantId,
transactionId: request.transactionId,
status: OtpChallengeStatusEnum.FAILED, status: OtpChallengeStatusEnum.FAILED,
error: { error: {
code: 'ORANGE_API_ERROR', code: 'ORANGE_API_ERROR',
@ -276,7 +274,6 @@ export class OrangeAdapter {
return { return {
challengeId, challengeId,
merchantId: originalRequest.merchantId, merchantId: originalRequest.merchantId,
transactionId: originalRequest.transactionId,
status: OtpChallengeStatusEnum.FAILED, status: OtpChallengeStatusEnum.FAILED,
error: { error: {
code: 'OTP_VERIFICATION_FAILED', code: 'OTP_VERIFICATION_FAILED',

View File

@ -4,7 +4,8 @@ import { Type } from 'class-transformer';
export enum OtpMethodEnum { export enum OtpMethodEnum {
SMS = 'SMS', SMS = 'SMS',
USSD = 'USSD', USSD = 'USSD',
IVR = 'IVR' IVR = 'IVR',
OTP_SMS_AUTH= 'OTP-SMS-AUTH'
} }
export enum IdentifierTypeEnum { export enum IdentifierTypeEnum {
@ -48,9 +49,6 @@ export class OtpChallengeRequestDto {
@IsNotEmpty() @IsNotEmpty()
merchantId: string; merchantId: string;
@IsString()
@IsNotEmpty()
transactionId: string;
@ValidateNested() @ValidateNested()
@Type(() => IdentifierDto) @Type(() => IdentifierDto)

View File

@ -19,7 +19,6 @@ export class OtpChallengeResponseDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
transactionId: string;
@IsEnum(OtpChallengeStatusEnum) @IsEnum(OtpChallengeStatusEnum)
@IsNotEmpty() @IsNotEmpty()

View File

@ -9,7 +9,7 @@ import {
HttpException, HttpException,
UseGuards, UseGuards,
Headers, Headers,
Logger Logger,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
@ -18,10 +18,14 @@ import {
ApiParam, ApiParam,
ApiQuery, ApiQuery,
ApiHeader, ApiHeader,
ApiBearerAuth ApiBearerAuth,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from './dto/challenge.response.dto'; import {
OtpChallengeResponseDto,
OtpChallengeStatusEnum,
} from './dto/challenge.response.dto';
import { OtpChallengeRequestDto } from './dto/challenge.request.dto'; import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
import { OtpChallengeService } from './otp.challenge.service';
/** /**
* Controller pour les endpoints de challenge OTP * Controller pour les endpoints de challenge OTP
@ -41,37 +45,40 @@ export class OtpChallengeController {
@Post('initiate') @Post('initiate')
@ApiOperation({ @ApiOperation({
summary: 'Initier un challenge OTP', summary: 'Initier un challenge OTP',
description: 'Envoie un code OTP au numéro de téléphone spécifié via SMS, USSD ou IVR' description:
'Envoie un code OTP au numéro de téléphone spécifié via SMS, USSD ou IVR',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.CREATED, status: HttpStatus.CREATED,
description: 'Challenge OTP initié avec succès', description: 'Challenge OTP initié avec succès',
type: OtpChallengeResponseDto type: OtpChallengeResponseDto,
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.BAD_REQUEST, status: HttpStatus.BAD_REQUEST,
description: 'Requête invalide' description: 'Requête invalide',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR, status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'Erreur serveur' description: 'Erreur serveur',
}) })
@ApiHeader({ @ApiHeader({
name: 'X-Merchant-ID', name: 'X-Merchant-ID',
description: 'Identifiant du merchant', description: 'Identifiant du merchant',
required: true required: true,
}) })
@ApiHeader({ @ApiHeader({
name: 'X-API-Key', name: 'X-API-Key',
description: 'Clé API du merchant', description: 'Clé API du merchant',
required: true required: true,
}) })
async initiateChallenge( async initiateChallenge(
@Body() request: OtpChallengeRequestDto, @Body() request: OtpChallengeRequestDto,
@Headers('x-merchant-id') merchantId: string, @Headers('X-Merchant-ID') merchantId: string,
@Headers('x-api-key') apiKey: string @Headers('X-API-Key') apiKey: string,
): Promise<OtpChallengeResponseDto> { ): Promise<OtpChallengeResponseDto> {
this.logger.log(`[INITIATE] Merchant: ${merchantId}, Transaction: ${request.transactionId}`); this.logger.log(
`[INITIATE] Challenge from: ${merchantId}, for customer: ${request.identifier.value}`,
);
try { try {
// Valider les headers // Valider les headers
@ -81,7 +88,7 @@ export class OtpChallengeController {
if (request.merchantId && request.merchantId !== merchantId) { if (request.merchantId && request.merchantId !== merchantId) {
throw new HttpException( throw new HttpException(
'Merchant ID in body does not match header', 'Merchant ID in body does not match header',
HttpStatus.BAD_REQUEST HttpStatus.BAD_REQUEST,
); );
} }
@ -89,11 +96,12 @@ export class OtpChallengeController {
request.merchantId = merchantId; request.merchantId = merchantId;
// Appeler le service // Appeler le service
const response = await this.otpChallengeService.initiateChallenge(request); const response =
await this.otpChallengeService.initiateChallenge(request);
// Logger le résultat // Logger le résultat
this.logger.log( this.logger.log(
`[INITIATE] Result - ChallengeId: ${response.challengeId}, Status: ${response.status}` `[INITIATE] Result - ChallengeId: ${response.challengeId}, Status: ${response.status}`,
); );
// Si échec, retourner une erreur HTTP appropriée // Si échec, retourner une erreur HTTP appropriée
@ -102,9 +110,9 @@ export class OtpChallengeController {
{ {
statusCode: HttpStatus.BAD_REQUEST, statusCode: HttpStatus.BAD_REQUEST,
message: response.error.message, message: response.error.message,
error: response.error error: response.error,
}, },
HttpStatus.BAD_REQUEST HttpStatus.BAD_REQUEST,
); );
} }
@ -120,9 +128,9 @@ export class OtpChallengeController {
{ {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR, statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Failed to initiate OTP challenge', message: 'Failed to initiate OTP challenge',
error: error.message error: error.message,
}, },
HttpStatus.INTERNAL_SERVER_ERROR HttpStatus.INTERNAL_SERVER_ERROR,
); );
} }
} }
@ -134,43 +142,45 @@ export class OtpChallengeController {
@Post(':challengeId/verify') @Post(':challengeId/verify')
@ApiOperation({ @ApiOperation({
summary: 'Vérifier un code OTP', summary: 'Vérifier un code OTP',
description: 'Vérifie le code OTP entré par l\'utilisateur' description: "Vérifie le code OTP entré par l'utilisateur",
}) })
@ApiParam({ @ApiParam({
name: 'challengeId', name: 'challengeId',
description: 'Identifiant du challenge', description: 'Identifiant du challenge',
type: String type: String,
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
description: 'Code OTP vérifié avec succès', description: 'Code OTP vérifié avec succès',
type: OtpChallengeResponseDto type: OtpChallengeResponseDto,
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.BAD_REQUEST, status: HttpStatus.BAD_REQUEST,
description: 'Code OTP invalide' description: 'Code OTP invalide',
}) })
@ApiResponse({ @ApiResponse({
status: HttpStatus.NOT_FOUND, status: HttpStatus.NOT_FOUND,
description: 'Challenge non trouvé' description: 'Challenge non trouvé',
}) })
@ApiHeader({ @ApiHeader({
name: 'X-Merchant-ID', name: 'X-Merchant-ID',
description: 'Identifiant du merchant', description: 'Identifiant du merchant',
required: true required: true,
}) })
@ApiHeader({ @ApiHeader({
name: 'X-API-Key', name: 'X-API-Key',
description: 'Clé API du merchant', description: 'Clé API du merchant',
required: true required: true,
}) })
async verifyOtp( async verifyOtp(
@Param('challengeId') challengeId: string, @Param('challengeId') challengeId: string,
@Body('otpCode') otpCode: string, @Body('otpCode') otpCode: string,
@Headers('x-merchant-id') merchantId: string, @Headers('x-merchant-id') merchantId: string,
@Headers('x-api-key') apiKey: string @Headers('x-api-key') apiKey: string,
): Promise<OtpChallengeResponseDto> { ): Promise<OtpChallengeResponseDto> {
this.logger.log(`[VERIFY] Merchant: ${merchantId}, Challenge: ${challengeId}`); this.logger.log(
`[VERIFY] Merchant: ${merchantId}, Challenge: ${challengeId}`,
);
try { try {
// Valider les headers // Valider les headers
@ -185,7 +195,7 @@ export class OtpChallengeController {
const response = await this.otpChallengeService.verifyOtp( const response = await this.otpChallengeService.verifyOtp(
challengeId, challengeId,
otpCode, otpCode,
merchantId merchantId,
); );
// Logger le résultat // Logger le résultat
@ -202,9 +212,9 @@ export class OtpChallengeController {
{ {
statusCode, statusCode,
message: response.error.message, message: response.error.message,
error: response.error error: response.error,
}, },
statusCode statusCode,
); );
} }
@ -220,179 +230,9 @@ export class OtpChallengeController {
{ {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR, statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Failed to verify OTP code', message: 'Failed to verify OTP code',
error: error.message error: error.message,
}, },
HttpStatus.INTERNAL_SERVER_ERROR HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
/**
* Endpoint 3: Obtenir le statut d'un challenge
* GET /api/v1/otp-challenge/:challengeId/status
*/
@Get(':challengeId/status')
@ApiOperation({
summary: 'Obtenir le statut d\'un challenge',
description: 'Récupère le statut actuel d\'un challenge OTP'
})
@ApiParam({
name: 'challengeId',
description: 'Identifiant du challenge',
type: String
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Statut du challenge récupéré avec succès',
type: OtpChallengeResponseDto
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Challenge non trouvé'
})
@ApiHeader({
name: 'X-Merchant-ID',
description: 'Identifiant du merchant',
required: true
})
@ApiHeader({
name: 'X-API-Key',
description: 'Clé API du merchant',
required: true
})
async getChallengeStatus(
@Param('challengeId') challengeId: string,
@Headers('x-merchant-id') merchantId: string,
@Headers('x-api-key') apiKey: string
): Promise<OtpChallengeResponseDto> {
this.logger.log(`[STATUS] Merchant: ${merchantId}, Challenge: ${challengeId}`);
try {
// Valider les headers
this.validateMerchantHeaders(merchantId, apiKey);
// Appeler le service
const response = await this.otpChallengeService.getChallengeStatus(
challengeId,
merchantId
);
// Si non trouvé
if (response.status === OtpChallengeStatusEnum.EXPIRED ||
(response.error && response.error.code === 'CHALLENGE_NOT_FOUND')) {
throw new HttpException(
{
statusCode: HttpStatus.NOT_FOUND,
message: 'Challenge not found or expired',
error: response.error
},
HttpStatus.NOT_FOUND
);
}
this.logger.log(`[STATUS] Result - Status: ${response.status}`);
return response;
} catch (error) {
this.logger.error(`[STATUS] Error: ${error.message}`, error.stack);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
{
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Failed to get challenge status',
error: error.message
},
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
/**
* Endpoint 4: Renvoyer un code OTP
* POST /api/v1/otp-challenge/:challengeId/resend
*/
@Post(':challengeId/resend')
@ApiOperation({
summary: 'Renvoyer un code OTP',
description: 'Renvoie un nouveau code OTP pour un challenge existant'
})
@ApiParam({
name: 'challengeId',
description: 'Identifiant du challenge',
type: String
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Code OTP renvoyé avec succès',
type: OtpChallengeResponseDto
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: 'Challenge non trouvé'
})
@ApiHeader({
name: 'X-Merchant-ID',
description: 'Identifiant du merchant',
required: true
})
@ApiHeader({
name: 'X-API-Key',
description: 'Clé API du merchant',
required: true
})
async resendOtp(
@Param('challengeId') challengeId: string,
@Headers('x-merchant-id') merchantId: string,
@Headers('x-api-key') apiKey: string
): Promise<OtpChallengeResponseDto> {
this.logger.log(`[RESEND] Merchant: ${merchantId}, Challenge: ${challengeId}`);
try {
// Valider les headers
this.validateMerchantHeaders(merchantId, apiKey);
// Appeler le service
const response = await this.otpChallengeService.resendOtp(
challengeId,
merchantId
);
// Si échec
if (response.status === OtpChallengeStatusEnum.FAILED && response.error) {
const statusCode =
response.error.code === 'CHALLENGE_NOT_FOUND'
? HttpStatus.NOT_FOUND
: HttpStatus.BAD_REQUEST;
throw new HttpException(
{
statusCode,
message: response.error.message,
error: response.error
},
statusCode
);
}
this.logger.log(`[RESEND] Result - New ChallengeId: ${response.challengeId}`);
return response;
} catch (error) {
this.logger.error(`[RESEND] Error: ${error.message}`, error.stack);
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(
{
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Failed to resend OTP code',
error: error.message
},
HttpStatus.INTERNAL_SERVER_ERROR
); );
} }
} }
@ -404,14 +244,14 @@ export class OtpChallengeController {
if (!merchantId || merchantId.trim().length === 0) { if (!merchantId || merchantId.trim().length === 0) {
throw new HttpException( throw new HttpException(
'X-Merchant-ID header is required', 'X-Merchant-ID header is required',
HttpStatus.UNAUTHORIZED HttpStatus.UNAUTHORIZED,
); );
} }
if (!apiKey || apiKey.trim().length === 0) { if (!apiKey || apiKey.trim().length === 0) {
throw new HttpException( throw new HttpException(
'X-API-Key header is required', 'X-API-Key header is required',
HttpStatus.UNAUTHORIZED HttpStatus.UNAUTHORIZED,
); );
} }

View File

@ -1,5 +1,7 @@
import { OtpChallengeRequestDto } from '../dtos/otp-challenge-request.dto'; import { OtpChallengeRequestDto } from "./dto/challenge.request.dto";
import { OtpChallengeResponseDto } from '../dtos/otp-challenge-response.dto'; import { OtpChallengeResponseDto } from "./dto/challenge.response.dto";
/** /**
* Interface générique pour le service de challenge OTP * Interface générique pour le service de challenge OTP
@ -26,25 +28,6 @@ export interface IOtpChallengeService {
merchantId: string merchantId: string
): Promise<OtpChallengeResponseDto>; ): Promise<OtpChallengeResponseDto>;
/**
* Obtenir le statut d'un challenge
* @param challengeId - L'identifiant du challenge
* @param merchantId - L'identifiant du merchant
* @returns Le statut actuel du challenge
*/
getChallengeStatus(
challengeId: string,
merchantId: string
): Promise<OtpChallengeResponseDto>;
/**
* Renvoyer un code OTP
* @param challengeId - L'identifiant du challenge
* @param merchantId - L'identifiant du merchant
* @returns La réponse du renvoi
*/
resendOtp(
challengeId: string,
merchantId: string
): Promise<OtpChallengeResponseDto>;
} }

View File

@ -24,23 +24,10 @@ export class OtpChallengeService implements IOtpChallengeService {
*/ */
async initiateChallenge(request: OtpChallengeRequestDto): Promise<OtpChallengeResponseDto> { async initiateChallenge(request: OtpChallengeRequestDto): Promise<OtpChallengeResponseDto> {
try { try {
// Valider la requête
this.validateRequest(request);
// Appeler l'adaptateur Orange // Appeler l'adaptateur Orange
const response = await this.orangeAdapter.initiateChallenge(request); const response = await this.orangeAdapter.initiateChallenge(request);
// Mettre en cache pour la vérification future
if (response.challengeId) {
this.challengeCache.set(response.challengeId, { request, response });
// Nettoyer le cache après expiration (par défaut 5 minutes)
const expirationTime = (response.expiresIn || 300) * 1000;
setTimeout(() => {
this.challengeCache.delete(response.challengeId);
}, expirationTime);
}
return response; return response;
} catch (error) { } catch (error) {
return this.createErrorResponse(request, 'INITIATE_FAILED', error.message); return this.createErrorResponse(request, 'INITIATE_FAILED', error.message);
@ -63,7 +50,6 @@ export class OtpChallengeService implements IOtpChallengeService {
return { return {
challengeId, challengeId,
merchantId, merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.FAILED, status: OtpChallengeStatusEnum.FAILED,
error: { error: {
code: 'CHALLENGE_NOT_FOUND', code: 'CHALLENGE_NOT_FOUND',
@ -78,7 +64,6 @@ export class OtpChallengeService implements IOtpChallengeService {
return { return {
challengeId, challengeId,
merchantId, merchantId,
transactionId: cached.request.transactionId,
status: OtpChallengeStatusEnum.FAILED, status: OtpChallengeStatusEnum.FAILED,
error: { error: {
code: 'MERCHANT_MISMATCH', code: 'MERCHANT_MISMATCH',
@ -105,7 +90,6 @@ export class OtpChallengeService implements IOtpChallengeService {
return { return {
challengeId, challengeId,
merchantId, merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.FAILED, status: OtpChallengeStatusEnum.FAILED,
error: { error: {
code: 'VERIFY_FAILED', code: 'VERIFY_FAILED',
@ -116,134 +100,12 @@ export class OtpChallengeService implements IOtpChallengeService {
} }
} }
/**
* Obtenir le statut d'un challenge
*/
async getChallengeStatus(
challengeId: string,
merchantId: string
): Promise<OtpChallengeResponseDto> {
const cached = this.challengeCache.get(challengeId);
if (!cached) {
return {
challengeId,
merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.EXPIRED,
error: {
code: 'CHALLENGE_NOT_FOUND',
message: 'Challenge not found or expired'
}
};
}
// Vérifier que le merchantId correspond
if (cached.request.merchantId !== merchantId) {
return {
challengeId,
merchantId,
transactionId: cached.request.transactionId,
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'MERCHANT_MISMATCH',
message: 'Merchant ID does not match'
}
};
}
return cached.response;
}
/**
* Renvoyer un code OTP
*/
async resendOtp(
challengeId: string,
merchantId: string
): Promise<OtpChallengeResponseDto> {
try {
// Récupérer le challenge depuis le cache
const cached = this.challengeCache.get(challengeId);
if (!cached) {
return {
challengeId,
merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'CHALLENGE_NOT_FOUND',
message: 'Challenge not found or expired'
}
};
}
// Vérifier que le merchantId correspond
if (cached.request.merchantId !== merchantId) {
return {
challengeId,
merchantId,
transactionId: cached.request.transactionId,
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'MERCHANT_MISMATCH',
message: 'Merchant ID does not match'
}
};
}
// Réinitier le challenge (nouvel envoi)
const response = await this.orangeAdapter.initiateChallenge(cached.request);
// Mettre à jour le cache avec le nouveau challengeId
if (response.challengeId) {
// Supprimer l'ancien
this.challengeCache.delete(challengeId);
// Ajouter le nouveau
this.challengeCache.set(response.challengeId, { request: cached.request, response });
}
return response;
} catch (error) {
return {
challengeId,
merchantId,
transactionId: '',
status: OtpChallengeStatusEnum.FAILED,
error: {
code: 'RESEND_FAILED',
message: error.message,
description: 'Failed to resend OTP code'
}
};
}
}
/** /**
* Valider la requête * Valider la requête
*/ */
private validateRequest(request: OtpChallengeRequestDto): void {
if (!request.merchantId) {
throw new Error('Merchant ID is required');
}
if (!request.transactionId) {
throw new Error('Transaction ID is required');
}
if (!request.identifier || !request.identifier.type || !request.identifier.value) {
throw new Error('Valid identifier is required');
}
if (!request.country) {
throw new Error('Country is required');
}
if (!request.service) {
throw new Error('Service is required');
}
}
/** /**
* Créer une réponse d'erreur * Créer une réponse d'erreur
@ -256,7 +118,6 @@ export class OtpChallengeService implements IOtpChallengeService {
return { return {
challengeId: '', challengeId: '',
merchantId: request.merchantId, merchantId: request.merchantId,
transactionId: request.transactionId,
status: OtpChallengeStatusEnum.FAILED, status: OtpChallengeStatusEnum.FAILED,
error: { error: {
code, code,