import { Controller, Post, Get, Body, Param, Query, HttpStatus, HttpException, UseGuards, Headers, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiHeader, ApiBearerAuth } from '@nestjs/swagger'; import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from './dto/challenge.response.dto'; import { OtpChallengeRequestDto } from './dto/challenge.request.dto'; /** * Controller pour les endpoints de challenge OTP * Architecture: Merchant -> Hub (ce controller) -> Orange (via adapter) */ @ApiTags('OTP Challenge') @Controller('api/v1/otp-challenge') export class OtpChallengeController { private readonly logger = new Logger(OtpChallengeController.name); constructor(private readonly otpChallengeService: OtpChallengeService) {} /** * Endpoint 1: Initier un challenge OTP * POST /api/v1/otp-challenge/initiate */ @Post('initiate') @ApiOperation({ summary: 'Initier un challenge OTP', description: 'Envoie un code OTP au numéro de téléphone spécifié via SMS, USSD ou IVR' }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Challenge OTP initié avec succès', type: OtpChallengeResponseDto }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Requête invalide' }) @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, description: 'Erreur serveur' }) @ApiHeader({ name: 'X-Merchant-ID', description: 'Identifiant du merchant', required: true }) @ApiHeader({ name: 'X-API-Key', description: 'Clé API du merchant', required: true }) async initiateChallenge( @Body() request: OtpChallengeRequestDto, @Headers('x-merchant-id') merchantId: string, @Headers('x-api-key') apiKey: string ): Promise { this.logger.log(`[INITIATE] Merchant: ${merchantId}, Transaction: ${request.transactionId}`); try { // Valider les headers this.validateMerchantHeaders(merchantId, apiKey); // S'assurer que le merchantId dans le body correspond au header if (request.merchantId && request.merchantId !== merchantId) { throw new HttpException( 'Merchant ID in body does not match header', HttpStatus.BAD_REQUEST ); } // Définir le merchantId depuis le header si non présent request.merchantId = merchantId; // Appeler le service const response = await this.otpChallengeService.initiateChallenge(request); // Logger le résultat this.logger.log( `[INITIATE] Result - ChallengeId: ${response.challengeId}, Status: ${response.status}` ); // Si échec, retourner une erreur HTTP appropriée if (response.status === OtpChallengeStatusEnum.FAILED && response.error) { throw new HttpException( { statusCode: HttpStatus.BAD_REQUEST, message: response.error.message, error: response.error }, HttpStatus.BAD_REQUEST ); } return response; } catch (error) { this.logger.error(`[INITIATE] Error: ${error.message}`, error.stack); if (error instanceof HttpException) { throw error; } throw new HttpException( { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Failed to initiate OTP challenge', error: error.message }, HttpStatus.INTERNAL_SERVER_ERROR ); } } /** * Endpoint 2: Vérifier un code OTP * POST /api/v1/otp-challenge/:challengeId/verify */ @Post(':challengeId/verify') @ApiOperation({ summary: 'Vérifier un code OTP', description: 'Vérifie le code OTP entré par l\'utilisateur' }) @ApiParam({ name: 'challengeId', description: 'Identifiant du challenge', type: String }) @ApiResponse({ status: HttpStatus.OK, description: 'Code OTP vérifié avec succès', type: OtpChallengeResponseDto }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Code OTP invalide' }) @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 verifyOtp( @Param('challengeId') challengeId: string, @Body('otpCode') otpCode: string, @Headers('x-merchant-id') merchantId: string, @Headers('x-api-key') apiKey: string ): Promise { this.logger.log(`[VERIFY] Merchant: ${merchantId}, Challenge: ${challengeId}`); try { // Valider les headers this.validateMerchantHeaders(merchantId, apiKey); // Valider le code OTP if (!otpCode || otpCode.trim().length === 0) { throw new HttpException('OTP code is required', HttpStatus.BAD_REQUEST); } // Appeler le service const response = await this.otpChallengeService.verifyOtp( challengeId, otpCode, merchantId ); // Logger le résultat this.logger.log(`[VERIFY] Result - Status: ${response.status}`); // Si échec, retourner une erreur appropriée 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 ); } return response; } catch (error) { this.logger.error(`[VERIFY] Error: ${error.message}`, error.stack); if (error instanceof HttpException) { throw error; } throw new HttpException( { statusCode: HttpStatus.INTERNAL_SERVER_ERROR, message: 'Failed to verify OTP code', error: error.message }, 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 { 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 { 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 ); } } /** * Valider les headers du merchant */ private validateMerchantHeaders(merchantId: string, apiKey: string): void { if (!merchantId || merchantId.trim().length === 0) { throw new HttpException( 'X-Merchant-ID header is required', HttpStatus.UNAUTHORIZED ); } if (!apiKey || apiKey.trim().length === 0) { throw new HttpException( 'X-API-Key header is required', HttpStatus.UNAUTHORIZED ); } // TODO: Implémenter la validation de l'API Key // Cette logique devrait vérifier que l'API Key est valide pour le merchant } }