fix it otp challenge
This commit is contained in:
parent
df7ae8dbeb
commit
f87650dcc2
@ -28,7 +28,7 @@ async function bootstrap() {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Payment Hub API')
|
||||
.setDescription('Unified DCB Payment Aggregation Platform')
|
||||
.setVersion('2.0.0')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth()
|
||||
.addTag('auth')
|
||||
.addTag('payments')
|
||||
@ -38,9 +38,14 @@ async function bootstrap() {
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
app.getHttpAdapter().get('/api/swagger-json', (req, res) => {
|
||||
res.json(document);
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`Application is running on: http://localhost:${port}`);
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/swagger-json`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
287
src/modules/challenge/adaptor/orange.adaptor.ts
Normal file
287
src/modules/challenge/adaptor/orange.adaptor.ts
Normal file
@ -0,0 +1,287 @@
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios';
|
||||
import {
|
||||
OrangeChallengeRequest,
|
||||
OrangeChallengeResponse,
|
||||
OrangeChallengeRequestBuilder
|
||||
} from './dtos/orange-challenge.dto';
|
||||
import {
|
||||
OrangeConfig,
|
||||
DEFAULT_ORANGE_CONFIG,
|
||||
COUNTRY_CODE_MAPPING,
|
||||
OTP_METHOD_MAPPING
|
||||
} from './config/orange.config';
|
||||
import { OtpChallengeRequestDto } from '../../dtos/otp-challenge-request.dto';
|
||||
import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from '../../dtos/otp-challenge-response.dto';
|
||||
|
||||
/**
|
||||
* Adaptateur pour l'API Orange DCB v2
|
||||
* Gère l'authentification OAuth2 et les appels à l'API Challenge
|
||||
*/
|
||||
export class OrangeAdapter {
|
||||
private axiosInstance: AxiosInstance;
|
||||
private config: OrangeConfig;
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiresAt: number = 0;
|
||||
|
||||
constructor(config: OrangeConfig) {
|
||||
this.config = { ...DEFAULT_ORANGE_CONFIG, ...config } as OrangeConfig;
|
||||
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: this.config.baseUrl,
|
||||
timeout: this.config.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': '*/*'
|
||||
}
|
||||
});
|
||||
|
||||
// Intercepteur pour gérer les erreurs
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
response => response,
|
||||
error => this.handleError(error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir un token OAuth2 depuis Orange
|
||||
*/
|
||||
private async getAccessToken(): Promise<string> {
|
||||
// Vérifier si le token est encore valide (avec une marge de 60 secondes)
|
||||
if (this.accessToken && Date.now() < this.tokenExpiresAt - 60000) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = Buffer.from(
|
||||
`${this.config.clientId}:${this.config.clientSecret}`
|
||||
).toString('base64');
|
||||
|
||||
const response = await axios.post(
|
||||
`${this.config.baseUrl}${this.config.tokenEndpoint}`,
|
||||
'grant_type=client_credentials',
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': '*/*'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.accessToken = response.data.access_token;
|
||||
const expiresIn = response.data.expires_in || 3600;
|
||||
this.tokenExpiresAt = Date.now() + (expiresIn * 1000);
|
||||
|
||||
return this.accessToken;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to obtain Orange access token: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir la requête générique en format Orange
|
||||
*/
|
||||
private mapToOrangeRequest(request: OtpChallengeRequestDto): OrangeChallengeRequest {
|
||||
const builder = new OrangeChallengeRequestBuilder();
|
||||
|
||||
// Mapper le pays
|
||||
const orangeCountry = COUNTRY_CODE_MAPPING[request.country] || request.country;
|
||||
builder.withCountry(orangeCountry);
|
||||
|
||||
// Mapper la méthode
|
||||
const orangeMethod = OTP_METHOD_MAPPING[request.method] || 'OTP-SMS-AUTH';
|
||||
builder.withMethod(orangeMethod);
|
||||
|
||||
// Ajouter les informations de base
|
||||
builder
|
||||
.withService(request.service)
|
||||
.withPartnerId(this.config.partnerId);
|
||||
|
||||
// Ajouter l'identifiant
|
||||
builder.withIdentifier(request.identifier.type, request.identifier.value);
|
||||
|
||||
// Ajouter le code de confirmation si présent
|
||||
if (request.confirmationCode) {
|
||||
builder.withConfirmationCode(request.confirmationCode);
|
||||
} else {
|
||||
builder.withConfirmationCode(''); // Orange requiert ce champ même vide
|
||||
}
|
||||
|
||||
// Configuration du message OTP
|
||||
const message = request.config?.message || this.config.defaultOtpMessage;
|
||||
builder.withMessage(message);
|
||||
|
||||
// Longueur de l'OTP
|
||||
const otpLength = request.config?.length || this.config.defaultOtpLength;
|
||||
builder.withOtpLength(otpLength);
|
||||
|
||||
// Nom de l'expéditeur
|
||||
const senderName = request.config?.senderName || this.config.defaultSenderName;
|
||||
builder.withSenderName(senderName);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir la réponse Orange en format générique
|
||||
*/
|
||||
private mapFromOrangeResponse(
|
||||
orangeResponse: OrangeChallengeResponse,
|
||||
request: OtpChallengeRequestDto
|
||||
): OtpChallengeResponseDto {
|
||||
const response: OtpChallengeResponseDto = {
|
||||
challengeId: orangeResponse.challengeId || '',
|
||||
merchantId: request.merchantId,
|
||||
transactionId: request.transactionId,
|
||||
status: this.mapOrangeStatus(orangeResponse),
|
||||
message: orangeResponse.message,
|
||||
expiresIn: orangeResponse.expiresIn,
|
||||
sessionId: orangeResponse.sessionId,
|
||||
requiresConfirmation: true,
|
||||
metadata: {
|
||||
provider: 'orange',
|
||||
country: request.country,
|
||||
method: request.method
|
||||
}
|
||||
};
|
||||
|
||||
// Ajouter l'erreur si présente
|
||||
if (orangeResponse.error) {
|
||||
response.error = {
|
||||
code: orangeResponse.error.code.toString(),
|
||||
message: orangeResponse.error.message,
|
||||
description: orangeResponse.error.description
|
||||
};
|
||||
response.status = OtpChallengeStatusEnum.FAILED;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapper le statut Orange vers le statut générique
|
||||
*/
|
||||
private mapOrangeStatus(orangeResponse: OrangeChallengeResponse): OtpChallengeStatusEnum {
|
||||
if (orangeResponse.error) {
|
||||
return OtpChallengeStatusEnum.FAILED;
|
||||
}
|
||||
|
||||
if (orangeResponse.challengeId) {
|
||||
return OtpChallengeStatusEnum.SENT;
|
||||
}
|
||||
|
||||
return OtpChallengeStatusEnum.PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gérer les erreurs HTTP
|
||||
*/
|
||||
private handleError(error: AxiosError): never {
|
||||
if (error.response) {
|
||||
const data = error.response.data as any;
|
||||
throw new Error(
|
||||
`Orange API Error: ${data?.error?.message || error.message} (Code: ${data?.error?.code || error.response.status})`
|
||||
);
|
||||
} else if (error.request) {
|
||||
throw new Error(`No response from Orange API: ${error.message}`);
|
||||
} else {
|
||||
throw new Error(`Request error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initier un challenge OTP via l'API Orange
|
||||
*/
|
||||
async initiateChallenge(request: OtpChallengeRequestDto): Promise<OtpChallengeResponseDto> {
|
||||
try {
|
||||
// Obtenir le token
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// Mapper la requête
|
||||
const orangeRequest = this.mapToOrangeRequest(request);
|
||||
|
||||
// Appeler l'API Orange
|
||||
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
|
||||
this.config.challengeEndpoint,
|
||||
orangeRequest,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mapper la réponse
|
||||
return this.mapFromOrangeResponse(response.data, request);
|
||||
} catch (error) {
|
||||
// En cas d'erreur, retourner une réponse avec le statut FAILED
|
||||
return {
|
||||
challengeId: '',
|
||||
merchantId: request.merchantId,
|
||||
transactionId: request.transactionId,
|
||||
status: OtpChallengeStatusEnum.FAILED,
|
||||
error: {
|
||||
code: 'ORANGE_API_ERROR',
|
||||
message: error.message,
|
||||
description: 'Failed to initiate OTP challenge with Orange'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier un code OTP (en appelant à nouveau l'API avec le code)
|
||||
*/
|
||||
async verifyOtp(
|
||||
challengeId: string,
|
||||
otpCode: string,
|
||||
originalRequest: OtpChallengeRequestDto
|
||||
): Promise<OtpChallengeResponseDto> {
|
||||
try {
|
||||
// Créer une nouvelle requête avec le code de confirmation
|
||||
const verifyRequest: OtpChallengeRequestDto = {
|
||||
...originalRequest,
|
||||
confirmationCode: otpCode
|
||||
};
|
||||
|
||||
// Obtenir le token
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
// Mapper la requête
|
||||
const orangeRequest = this.mapToOrangeRequest(verifyRequest);
|
||||
|
||||
// Appeler l'API Orange pour vérification
|
||||
const response = await this.axiosInstance.post<OrangeChallengeResponse>(
|
||||
this.config.challengeEndpoint,
|
||||
orangeRequest,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mapper la réponse
|
||||
const mappedResponse = this.mapFromOrangeResponse(response.data, verifyRequest);
|
||||
|
||||
// Si pas d'erreur, c'est vérifié
|
||||
if (!mappedResponse.error) {
|
||||
mappedResponse.status = OtpChallengeStatusEnum.VERIFIED;
|
||||
}
|
||||
|
||||
return mappedResponse;
|
||||
} catch (error) {
|
||||
return {
|
||||
challengeId,
|
||||
merchantId: originalRequest.merchantId,
|
||||
transactionId: originalRequest.transactionId,
|
||||
status: OtpChallengeStatusEnum.FAILED,
|
||||
error: {
|
||||
code: 'OTP_VERIFICATION_FAILED',
|
||||
message: error.message,
|
||||
description: 'Failed to verify OTP code'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/modules/challenge/adaptor/orange.config.ts
Normal file
46
src/modules/challenge/adaptor/orange.config.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export interface OrangeConfig {
|
||||
baseUrl: string;
|
||||
partnerId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
defaultService: string;
|
||||
defaultOtpLength: number;
|
||||
defaultSenderName: string;
|
||||
defaultOtpMessage: string;
|
||||
tokenEndpoint: string;
|
||||
challengeEndpoint: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ORANGE_CONFIG: Partial<OrangeConfig> = {
|
||||
defaultOtpLength: 4,
|
||||
defaultOtpMessage: 'To confirm your purchase please enter the code %OTP%',
|
||||
tokenEndpoint: '/oauth/v3/token',
|
||||
challengeEndpoint: '/challenge/v1/challenges',
|
||||
timeout: 30000, // 30 secondes
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping des codes pays ISO vers les codes Orange
|
||||
*/
|
||||
export const COUNTRY_CODE_MAPPING: Record<string, string> = {
|
||||
'SN': 'SEN', // Sénégal
|
||||
'CI': 'CIV', // Côte d'Ivoire
|
||||
'CM': 'CMR', // Cameroun
|
||||
'CD': 'COD', // RD Congo
|
||||
'BF': 'BFA', // Burkina Faso
|
||||
'TN': 'TUN', // Tunisie
|
||||
'ML': 'MLI', // Mali
|
||||
'GN': 'GIN', // Guinée
|
||||
'NE': 'NER', // Niger
|
||||
'MG': 'MDG', // Madagascar
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping des méthodes OTP génériques vers Orange
|
||||
*/
|
||||
export const OTP_METHOD_MAPPING: Record<string, string> = {
|
||||
'SMS': 'OTP-SMS-AUTH',
|
||||
'USSD': 'OTP-USSD-AUTH',
|
||||
'IVR': 'OTP-IVR-AUTH',
|
||||
};
|
||||
82
src/modules/challenge/dto/challenge.request.dto.ts
Normal file
82
src/modules/challenge/dto/challenge.request.dto.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, ValidateNested, IsArray, IsNumber, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export enum OtpMethodEnum {
|
||||
SMS = 'SMS',
|
||||
USSD = 'USSD',
|
||||
IVR = 'IVR'
|
||||
}
|
||||
|
||||
export enum IdentifierTypeEnum {
|
||||
MSISDN = 'MSISDN',
|
||||
ISE2 = 'ISE2',
|
||||
SUBSCRIBER_ID = 'SUBSCRIBER_ID'
|
||||
}
|
||||
|
||||
export class IdentifierDto {
|
||||
@IsEnum(IdentifierTypeEnum)
|
||||
@IsNotEmpty()
|
||||
type: IdentifierTypeEnum;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class OtpConfigDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
message?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(4)
|
||||
@Max(8)
|
||||
length?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
senderName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export class OtpChallengeRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
merchantId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
transactionId: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => IdentifierDto)
|
||||
identifier: IdentifierDto;
|
||||
|
||||
@IsEnum(OtpMethodEnum)
|
||||
@IsNotEmpty()
|
||||
method: OtpMethodEnum;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
country: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
service: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => OtpConfigDto)
|
||||
config?: OtpConfigDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
confirmationCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
53
src/modules/challenge/dto/challenge.response.dto.ts
Normal file
53
src/modules/challenge/dto/challenge.response.dto.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, IsNumber, IsBoolean } from 'class-validator';
|
||||
|
||||
export enum OtpChallengeStatusEnum {
|
||||
PENDING = 'PENDING',
|
||||
SENT = 'SENT',
|
||||
VERIFIED = 'VERIFIED',
|
||||
FAILED = 'FAILED',
|
||||
EXPIRED = 'EXPIRED'
|
||||
}
|
||||
|
||||
export class OtpChallengeResponseDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
challengeId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
merchantId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
transactionId: string;
|
||||
|
||||
@IsEnum(OtpChallengeStatusEnum)
|
||||
@IsNotEmpty()
|
||||
status: OtpChallengeStatusEnum;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
message?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
expiresIn?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresConfirmation?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
421
src/modules/challenge/otp.challenge.controller.ts
Normal file
421
src/modules/challenge/otp.challenge.controller.ts
Normal file
@ -0,0 +1,421 @@
|
||||
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<OtpChallengeResponseDto> {
|
||||
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<OtpChallengeResponseDto> {
|
||||
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<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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
50
src/modules/challenge/otp.challenge.interface.ts
Normal file
50
src/modules/challenge/otp.challenge.interface.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { OtpChallengeRequestDto } from '../dtos/otp-challenge-request.dto';
|
||||
import { OtpChallengeResponseDto } from '../dtos/otp-challenge-response.dto';
|
||||
|
||||
/**
|
||||
* Interface générique pour le service de challenge OTP
|
||||
* Permet de gérer différents fournisseurs (Orange, MTN, etc.)
|
||||
*/
|
||||
export interface IOtpChallengeService {
|
||||
/**
|
||||
* Initier un challenge OTP
|
||||
* @param request - Les données du challenge
|
||||
* @returns La réponse du challenge avec le challengeId et le status
|
||||
*/
|
||||
initiateChallenge(request: OtpChallengeRequestDto): Promise<OtpChallengeResponseDto>;
|
||||
|
||||
/**
|
||||
* Vérifier un code OTP
|
||||
* @param challengeId - L'identifiant du challenge
|
||||
* @param otpCode - Le code OTP à vérifier
|
||||
* @param merchantId - L'identifiant du merchant
|
||||
* @returns La réponse de vérification
|
||||
*/
|
||||
verifyOtp(
|
||||
challengeId: string,
|
||||
otpCode: string,
|
||||
merchantId: string
|
||||
): 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>;
|
||||
}
|
||||
39
src/modules/challenge/otp.challenge.module.ts
Normal file
39
src/modules/challenge/otp.challenge.module.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { OtpChallengeController } from './otp.challenge.controller';
|
||||
import { OrangeConfig } from './adaptor/orange.config';
|
||||
import { OtpChallengeService } from './otp.challenge.service';
|
||||
|
||||
/**
|
||||
* Module pour le challenge OTP
|
||||
* Gère l'injection de dépendances et la configuration
|
||||
*/
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [OtpChallengeController],
|
||||
providers: [
|
||||
{
|
||||
provide: 'ORANGE_CONFIG',
|
||||
useFactory: (configService: ConfigService): OrangeConfig => ({
|
||||
baseUrl: configService.get<string>('ORANGE_BASE_URL', 'https://api-gateway.app.cameleonapp.com/api-orange-dcb/1.0.0'),
|
||||
partnerId: configService.get<string>('ORANGE_PARTNER_ID', 'PDKSUB'),
|
||||
clientId: configService.get<string>('ORANGE_CLIENT_ID', 'admin'),
|
||||
clientSecret: configService.get<string>('ORANGE_CLIENT_SECRET', 'admin'),
|
||||
defaultService: configService.get<string>('ORANGE_DEFAULT_SERVICE', 'DCB_SERVICE'),
|
||||
defaultOtpLength: configService.get<number>('ORANGE_DEFAULT_OTP_LENGTH', 4),
|
||||
defaultSenderName: configService.get<string>('ORANGE_DEFAULT_SENDER_NAME', 'OTP'),
|
||||
defaultOtpMessage: configService.get<string>(
|
||||
'ORANGE_DEFAULT_OTP_MESSAGE',
|
||||
'To confirm your purchase please enter the code %OTP%'
|
||||
),
|
||||
tokenEndpoint: '/oauth/v3/token',
|
||||
challengeEndpoint: '/challenge/v1/challenges',
|
||||
timeout: configService.get<number>('ORANGE_TIMEOUT', 30000),
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
},
|
||||
OtpChallengeService,
|
||||
],
|
||||
exports: [OtpChallengeService],
|
||||
})
|
||||
export class OtpChallengeModule {}
|
||||
268
src/modules/challenge/otp.challenge.service.ts
Normal file
268
src/modules/challenge/otp.challenge.service.ts
Normal file
@ -0,0 +1,268 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import type { OrangeConfig } from './adaptor/orange.config';
|
||||
import { OrangeAdapter } from './adaptor/orange.adaptor';
|
||||
import { OtpChallengeRequestDto } from './dto/challenge.request.dto';
|
||||
import { IOtpChallengeService } from './otp.challenge.interface';
|
||||
import { OtpChallengeResponseDto, OtpChallengeStatusEnum } from './dto/challenge.response.dto';
|
||||
|
||||
/**
|
||||
* Service Hub pour gérer les challenges OTP
|
||||
* Utilise l'adaptateur Orange pour communiquer avec l'API Orange DCB
|
||||
*/
|
||||
@Injectable()
|
||||
export class OtpChallengeService implements IOtpChallengeService {
|
||||
private orangeAdapter: OrangeAdapter;
|
||||
private challengeCache: Map<string, { request: OtpChallengeRequestDto; response: OtpChallengeResponseDto }>;
|
||||
|
||||
constructor(@Inject('ORANGE_CONFIG') private readonly orangeConfig: OrangeConfig) {
|
||||
this.orangeAdapter = new OrangeAdapter(orangeConfig);
|
||||
this.challengeCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initier un challenge OTP
|
||||
*/
|
||||
async initiateChallenge(request: OtpChallengeRequestDto): Promise<OtpChallengeResponseDto> {
|
||||
try {
|
||||
// Valider la requête
|
||||
this.validateRequest(request);
|
||||
|
||||
// Appeler l'adaptateur Orange
|
||||
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;
|
||||
} catch (error) {
|
||||
return this.createErrorResponse(request, 'INITIATE_FAILED', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifier un code OTP
|
||||
*/
|
||||
async verifyOtp(
|
||||
challengeId: string,
|
||||
otpCode: 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',
|
||||
description: 'The challenge ID is invalid or has 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',
|
||||
description: 'The provided merchant ID does not match the challenge'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Appeler l'adaptateur pour vérifier
|
||||
const response = await this.orangeAdapter.verifyOtp(
|
||||
challengeId,
|
||||
otpCode,
|
||||
cached.request
|
||||
);
|
||||
|
||||
// Mettre à jour le cache
|
||||
if (response.status === OtpChallengeStatusEnum.VERIFIED) {
|
||||
this.challengeCache.set(challengeId, { request: cached.request, response });
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
return {
|
||||
challengeId,
|
||||
merchantId,
|
||||
transactionId: '',
|
||||
status: OtpChallengeStatusEnum.FAILED,
|
||||
error: {
|
||||
code: 'VERIFY_FAILED',
|
||||
message: error.message,
|
||||
description: 'Failed to verify OTP code'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
private createErrorResponse(
|
||||
request: OtpChallengeRequestDto,
|
||||
code: string,
|
||||
message: string
|
||||
): OtpChallengeResponseDto {
|
||||
return {
|
||||
challengeId: '',
|
||||
merchantId: request.merchantId,
|
||||
transactionId: request.transactionId,
|
||||
status: OtpChallengeStatusEnum.FAILED,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
description: 'Failed to process OTP challenge request'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
75
src/modules/operators/dto/operator.dto.ts
Normal file
75
src/modules/operators/dto/operator.dto.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { IsString, IsOptional, IsBoolean } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class OperatorResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
code: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
country: string;
|
||||
|
||||
@ApiProperty()
|
||||
active: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
features: any;
|
||||
}
|
||||
|
||||
export class OperatorConfigDto {
|
||||
@ApiProperty()
|
||||
code: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
baseUrl: string;
|
||||
|
||||
@ApiProperty()
|
||||
endpoints: any;
|
||||
|
||||
@ApiProperty()
|
||||
headers: any;
|
||||
|
||||
@ApiProperty()
|
||||
features: any;
|
||||
}
|
||||
|
||||
export class TestConnectionDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
testMsisdn: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
country: string;
|
||||
}
|
||||
|
||||
export class OperatorStatsDto {
|
||||
@ApiProperty()
|
||||
operatorCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
totalPayments: number;
|
||||
|
||||
@ApiProperty()
|
||||
successfulPayments: number;
|
||||
|
||||
@ApiProperty()
|
||||
failedPayments: number;
|
||||
|
||||
@ApiProperty()
|
||||
successRate: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalRevenue: number;
|
||||
|
||||
@ApiProperty()
|
||||
uniqueUsers: number;
|
||||
}
|
||||
@ -1,11 +1,182 @@
|
||||
//todo
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { OperatorsService } from './operators.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import {
|
||||
OperatorConfigDto,
|
||||
OperatorResponseDto,
|
||||
TestConnectionDto,
|
||||
OperatorStatsDto,
|
||||
} from './dto/operator.dto';
|
||||
|
||||
@Controller()
|
||||
export class OperatorsController{
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
|
||||
@ApiTags('operators')
|
||||
@Controller('operators')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class OperatorsController {
|
||||
constructor(private readonly operatorsService: OperatorsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all available operators' })
|
||||
@ApiQuery({ name: 'country', required: false })
|
||||
@ApiQuery({ name: 'active', required: false, type: Boolean })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of operators',
|
||||
type: [OperatorResponseDto],
|
||||
})
|
||||
async listOperators(
|
||||
@Query('country') country?: string,
|
||||
@Query('active') active?: boolean,
|
||||
) {
|
||||
return this.operatorsService.listOperators({ country, active });
|
||||
}
|
||||
|
||||
@Get('supported-countries')
|
||||
@ApiOperation({ summary: 'Get list of supported countries' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of supported countries with operators',
|
||||
})
|
||||
async getSupportedCountries() {
|
||||
return this.operatorsService.getSupportedCountries();
|
||||
}
|
||||
|
||||
@Get(':operatorCode/config')
|
||||
@ApiOperation({ summary: 'Get operator configuration' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Operator configuration',
|
||||
type: OperatorConfigDto,
|
||||
})
|
||||
async getOperatorConfig(@Param('operatorCode') operatorCode: string) {
|
||||
return this.operatorsService.getOperatorConfig(operatorCode);
|
||||
}
|
||||
|
||||
@Get(':operatorCode/status')
|
||||
@ApiOperation({ summary: 'Check operator service status' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Operator service status',
|
||||
})
|
||||
async checkOperatorStatus(@Param('operatorCode') operatorCode: string) {
|
||||
return this.operatorsService.checkOperatorStatus(operatorCode);
|
||||
}
|
||||
|
||||
@Post(':operatorCode/test-connection')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Test connection to operator' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Connection test result',
|
||||
})
|
||||
async testConnection(
|
||||
@Param('operatorCode') operatorCode: string,
|
||||
@Body() testDto: TestConnectionDto,
|
||||
) {
|
||||
return this.operatorsService.testConnection(operatorCode, testDto);
|
||||
}
|
||||
|
||||
@Get(':operatorCode/statistics')
|
||||
@ApiOperation({ summary: 'Get operator statistics' })
|
||||
@ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly', 'yearly'] })
|
||||
@ApiQuery({ name: 'startDate', required: false, type: Date })
|
||||
@ApiQuery({ name: 'endDate', required: false, type: Date })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Operator statistics',
|
||||
type: OperatorStatsDto,
|
||||
})
|
||||
async getOperatorStatistics(
|
||||
@Request() req,
|
||||
@Param('operatorCode') operatorCode: string,
|
||||
@Query('period') period: string = 'monthly',
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
) {
|
||||
return this.operatorsService.getOperatorStatistics({
|
||||
partnerId: req.user.partnerId,
|
||||
operatorCode,
|
||||
period,
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':operatorCode/health')
|
||||
@ApiOperation({ summary: 'Get operator health metrics' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Operator health metrics',
|
||||
})
|
||||
async getOperatorHealth(@Param('operatorCode') operatorCode: string) {
|
||||
return this.operatorsService.getOperatorHealth(operatorCode);
|
||||
}
|
||||
|
||||
@Get('detect/:msisdn')
|
||||
@ApiOperation({ summary: 'Detect operator from MSISDN' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Detected operator information',
|
||||
})
|
||||
async detectOperator(@Param('msisdn') msisdn: string) {
|
||||
return this.operatorsService.detectOperatorByMsisdn(msisdn);
|
||||
}
|
||||
|
||||
@Get(':operatorCode/pricing')
|
||||
@ApiOperation({ summary: 'Get operator pricing information' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Operator pricing details',
|
||||
})
|
||||
async getOperatorPricing(@Param('operatorCode') operatorCode: string) {
|
||||
return this.operatorsService.getOperatorPricing(operatorCode);
|
||||
}
|
||||
|
||||
@Get(':operatorCode/capabilities')
|
||||
@ApiOperation({ summary: 'Get operator capabilities' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Operator capabilities and features',
|
||||
})
|
||||
async getOperatorCapabilities(@Param('operatorCode') operatorCode: string) {
|
||||
return this.operatorsService.getOperatorCapabilities(operatorCode);
|
||||
}
|
||||
|
||||
@Put(':operatorCode/toggle-status')
|
||||
@ApiOperation({ summary: 'Enable/Disable operator (Admin only)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Operator status updated',
|
||||
})
|
||||
async toggleOperatorStatus(
|
||||
@Request() req,
|
||||
@Param('operatorCode') operatorCode: string,
|
||||
@Body() body: { active: boolean; reason?: string },
|
||||
) {
|
||||
// Add admin check here
|
||||
return this.operatorsService.toggleOperatorStatus(
|
||||
operatorCode,
|
||||
body.active,
|
||||
body.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,416 @@
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaService } from "src/shared/services/prisma.service";
|
||||
import { OperatorAdapterFactory } from "./adapters/operator-adapter.factory";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
//todo tomaj
|
||||
export class OperatorsService{
|
||||
getAdapter(code: any, country: any) :any{
|
||||
throw new Error('Method not implemented.');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly adapterFactory: OperatorAdapterFactory,
|
||||
private readonly httpService: HttpService,
|
||||
) {}
|
||||
|
||||
getAdapter(operator: string, country: string) {
|
||||
return this.adapterFactory.getAdapter(operator, country);
|
||||
}
|
||||
|
||||
async listOperators(filters?: { country?: string; active?: boolean }) {
|
||||
const where: any = {};
|
||||
|
||||
if (filters?.country) {
|
||||
where.country = filters.country;
|
||||
}
|
||||
|
||||
if (filters?.active !== undefined) {
|
||||
where.active = filters.active;
|
||||
}
|
||||
|
||||
const operators = await this.prisma.operator.findMany({
|
||||
where,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return operators.map(op => ({
|
||||
id: op.id,
|
||||
code: op.code,
|
||||
name: op.name,
|
||||
country: op.country,
|
||||
active: op.active,
|
||||
features: this.extractFeatures(op.config),
|
||||
}));
|
||||
}
|
||||
|
||||
async getSupportedCountries() {
|
||||
const operators = await this.prisma.operator.findMany({
|
||||
where: { active: true },
|
||||
distinct: ['country'],
|
||||
select: {
|
||||
country: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const countriesMap = new Map();
|
||||
|
||||
operators.forEach(op => {
|
||||
if (!countriesMap.has(op.country)) {
|
||||
countriesMap.set(op.country, {
|
||||
code: op.country,
|
||||
name: this.getCountryName(op.country),
|
||||
operators: [],
|
||||
});
|
||||
}
|
||||
countriesMap.get(op.country).operators.push({
|
||||
code: op.code,
|
||||
name: op.name,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(countriesMap.values());
|
||||
}
|
||||
|
||||
async getOperatorConfig(operatorCode: string) {
|
||||
const operator = await this.prisma.operator.findFirst({
|
||||
where: { code: operatorCode as any },
|
||||
});
|
||||
|
||||
if (!operator) {
|
||||
throw new NotFoundException('Operator not found');
|
||||
}
|
||||
|
||||
const config = this.configService.get(`operators.${operatorCode}_${operator.country}`);
|
||||
|
||||
return {
|
||||
...operator,
|
||||
endpoints: config?.endpoints,
|
||||
headers: config?.headers,
|
||||
features: this.extractFeatures(operator.config),
|
||||
};
|
||||
}
|
||||
|
||||
async checkOperatorStatus(operatorCode: string) {
|
||||
const operator = await this.prisma.operator.findFirst({
|
||||
where: { code: operatorCode as any },
|
||||
});
|
||||
|
||||
if (!operator) {
|
||||
throw new NotFoundException('Operator not found');
|
||||
}
|
||||
|
||||
const config = operator.config as any;
|
||||
const healthEndpoint = config?.healthEndpoint || '/health';
|
||||
const baseUrl = config?.baseUrl;
|
||||
|
||||
if (!baseUrl) {
|
||||
return {
|
||||
status: 'UNKNOWN',
|
||||
message: 'No health endpoint configured',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${baseUrl}${healthEndpoint}`, {
|
||||
timeout: 5000,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'OPERATIONAL',
|
||||
responseTime: response.headers['x-response-time'] || 'N/A',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'DOWN',
|
||||
error: error.message,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(operatorCode: string, testDto: any) {
|
||||
const adapter = this.adapterFactory.getAdapter(operatorCode, testDto.country);
|
||||
|
||||
try {
|
||||
const result = await adapter.initializeAuth({
|
||||
msisdn: testDto.testMsisdn,
|
||||
country: testDto.country,
|
||||
metadata: { test: true },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
details: {
|
||||
sessionId: result.sessionId,
|
||||
authMethod: result.authMethod,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Connection failed',
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getOperatorStatistics(params: any) {
|
||||
const { partnerId, operatorCode, startDate, endDate } = params;
|
||||
|
||||
const where: any = {
|
||||
partner: { id: partnerId },
|
||||
user: {
|
||||
operator: { code: operatorCode as any },
|
||||
},
|
||||
};
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) where.createdAt.gte = startDate;
|
||||
if (endDate) where.createdAt.lte = endDate;
|
||||
}
|
||||
|
||||
const [
|
||||
totalPayments,
|
||||
successfulPayments,
|
||||
totalRevenue,
|
||||
uniqueUsers,
|
||||
] = await Promise.all([
|
||||
this.prisma.payment.count({ where }),
|
||||
this.prisma.payment.count({ where: { ...where, status: 'SUCCESS' } }),
|
||||
this.prisma.payment.aggregate({
|
||||
where: { ...where, status: 'SUCCESS' },
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
this.prisma.payment.findMany({
|
||||
where,
|
||||
distinct: ['userId'],
|
||||
select: { userId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const successRate = totalPayments > 0
|
||||
? (successfulPayments / totalPayments) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
operatorCode,
|
||||
totalPayments,
|
||||
successfulPayments,
|
||||
failedPayments: totalPayments - successfulPayments,
|
||||
successRate: Math.round(successRate * 100) / 100,
|
||||
totalRevenue: totalRevenue._sum.amount || 0,
|
||||
uniqueUsers: uniqueUsers.length,
|
||||
period: params.period,
|
||||
};
|
||||
}
|
||||
|
||||
async getOperatorHealth(operatorCode: string) {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
|
||||
const [recentPayments, recentFailures] = await Promise.all([
|
||||
this.prisma.payment.count({
|
||||
where: {
|
||||
createdAt: { gte: oneHourAgo },
|
||||
user: {
|
||||
operator: { code: operatorCode as any },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.payment.count({
|
||||
where: {
|
||||
createdAt: { gte: oneHourAgo },
|
||||
status: 'FAILED',
|
||||
user: {
|
||||
operator: { code: operatorCode as any },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const errorRate = recentPayments > 0
|
||||
? (recentFailures / recentPayments) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
operatorCode,
|
||||
status: errorRate > 20 ? 'DEGRADED' : 'HEALTHY',
|
||||
metrics: {
|
||||
recentTransactions: recentPayments,
|
||||
recentFailures,
|
||||
errorRate: Math.round(errorRate * 100) / 100,
|
||||
lastChecked: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async detectOperatorByMsisdn(msisdn: string) {
|
||||
const cleanMsisdn = msisdn.replace(/[^0-9]/g, '');
|
||||
|
||||
// Extract country code
|
||||
const countryCode = this.extractCountryCode(cleanMsisdn);
|
||||
|
||||
if (!countryCode) {
|
||||
throw new BadRequestException('Unable to detect country from MSISDN');
|
||||
}
|
||||
|
||||
// Get operator based on prefix
|
||||
const operator = this.detectOperatorFromPrefix(cleanMsisdn, countryCode);
|
||||
|
||||
return {
|
||||
msisdn: cleanMsisdn,
|
||||
countryCode,
|
||||
operator,
|
||||
country: this.getCountryFromCode(countryCode),
|
||||
};
|
||||
}
|
||||
|
||||
async getOperatorPricing(operatorCode: string) {
|
||||
const operator = await this.prisma.operator.findFirst({
|
||||
where: { code: operatorCode as any },
|
||||
});
|
||||
|
||||
if (!operator) {
|
||||
throw new NotFoundException('Operator not found');
|
||||
}
|
||||
|
||||
const config = operator.config as any;
|
||||
|
||||
return {
|
||||
operatorCode,
|
||||
pricing: config?.pricing || {
|
||||
transactionFee: 0.02,
|
||||
percentageFee: 0.03,
|
||||
currency: 'USD',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getOperatorCapabilities(operatorCode: string) {
|
||||
const operator = await this.prisma.operator.findFirst({
|
||||
where: { code: operatorCode as any },
|
||||
});
|
||||
|
||||
if (!operator) {
|
||||
throw new NotFoundException('Operator not found');
|
||||
}
|
||||
|
||||
const config = operator.config as any;
|
||||
|
||||
return {
|
||||
operatorCode,
|
||||
capabilities: {
|
||||
authMethods: config?.authMethods || ['OTP'],
|
||||
paymentMethods: ['DCB'],
|
||||
supportedCurrencies: config?.currencies || ['XOF'],
|
||||
features: {
|
||||
subscription: config?.features?.subscription || false,
|
||||
refund: config?.features?.refund || false,
|
||||
partialRefund: config?.features?.partialRefund || false,
|
||||
sms: config?.features?.sms || true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async toggleOperatorStatus(operatorCode: string, active: boolean, reason?: string) {
|
||||
const operator = await this.prisma.operator.findFirst({
|
||||
where: { code: operatorCode as any },
|
||||
});
|
||||
|
||||
if (!operator) {
|
||||
throw new NotFoundException('Operator not found');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.operator.update({
|
||||
where: { id: operator.id },
|
||||
data: {
|
||||
active,
|
||||
config: {
|
||||
...(operator.config as any),
|
||||
statusChangeReason: reason,
|
||||
statusChangedAt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
operatorCode,
|
||||
active: updated.active,
|
||||
message: `Operator ${active ? 'enabled' : 'disabled'} successfully`,
|
||||
};
|
||||
}
|
||||
|
||||
private extractFeatures(config: any) {
|
||||
return {
|
||||
subscription: config?.features?.subscription || false,
|
||||
refund: config?.features?.refund || false,
|
||||
sms: config?.features?.sms || true,
|
||||
ussd: config?.features?.ussd || false,
|
||||
};
|
||||
}
|
||||
|
||||
private getCountryName(code: string): string {
|
||||
const countries = {
|
||||
CI: 'Côte d\'Ivoire',
|
||||
SN: 'Sénégal',
|
||||
CM: 'Cameroun',
|
||||
CD: 'RD Congo',
|
||||
TN: 'Tunisie',
|
||||
BF: 'Burkina Faso',
|
||||
};
|
||||
return countries[code] || code;
|
||||
}
|
||||
|
||||
private extractCountryCode(msisdn: string): string {
|
||||
const prefixes = {
|
||||
'225': 'CI',
|
||||
'221': 'SN',
|
||||
'237': 'CM',
|
||||
'243': 'CD',
|
||||
'216': 'TN',
|
||||
'226': 'BF',
|
||||
};
|
||||
|
||||
for (const [prefix, country] of Object.entries(prefixes)) {
|
||||
if (msisdn.startsWith(prefix)) {
|
||||
return country;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private detectOperatorFromPrefix(msisdn: string, countryCode: string): string {
|
||||
// Simplified logic - should be extended based on actual prefix mappings
|
||||
const prefixMaps = {
|
||||
CI: {
|
||||
'07': 'ORANGE',
|
||||
'08': 'ORANGE',
|
||||
'05': 'MTN',
|
||||
'04': 'MTN',
|
||||
},
|
||||
// Add other countries
|
||||
};
|
||||
|
||||
const countryMap = prefixMaps[countryCode];
|
||||
if (!countryMap) return 'UNKNOWN';
|
||||
|
||||
const localNumber = msisdn.replace(/^225|^221|^237/, '');
|
||||
const prefix = localNumber.substring(0, 2);
|
||||
|
||||
return countryMap[prefix] || 'UNKNOWN';
|
||||
}
|
||||
|
||||
private getCountryFromCode(code: string): string {
|
||||
return this.getCountryName(code);
|
||||
}
|
||||
|
||||
}
|
||||
@ -87,3 +87,92 @@ export class UpdateCallbacksDto {
|
||||
@IsUrl()
|
||||
webhook?: string;
|
||||
}
|
||||
|
||||
export class LoginPartnerDto {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
currentPassword: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export class UpdatePartnerDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
companyInfo?: {
|
||||
legalName?: string;
|
||||
taxId?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
};
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class PartnerResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
status: string;
|
||||
|
||||
@ApiProperty()
|
||||
apiKey: string;
|
||||
|
||||
@ApiProperty()
|
||||
country: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
companyInfo?: any;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class PartnerStatsResponseDto {
|
||||
@ApiProperty()
|
||||
totalUsers: number;
|
||||
|
||||
@ApiProperty()
|
||||
activeSubscriptions: number;
|
||||
|
||||
@ApiProperty()
|
||||
todayPayments: number;
|
||||
|
||||
@ApiProperty()
|
||||
monthRevenue: number;
|
||||
|
||||
@ApiProperty()
|
||||
successRate: number;
|
||||
|
||||
@ApiProperty()
|
||||
avgPaymentAmount: number;
|
||||
}
|
||||
|
||||
@ -1,9 +1,342 @@
|
||||
//todo
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
@Controller()
|
||||
export class PartnersController{
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Patch,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { PartnersService } from './partners.service';
|
||||
import {
|
||||
CreatePartnerDto,
|
||||
UpdatePartnerDto,
|
||||
UpdateCallbacksDto,
|
||||
LoginPartnerDto,
|
||||
ChangePasswordDto,
|
||||
PartnerResponseDto,
|
||||
PartnerStatsResponseDto,
|
||||
} from './dto/partner.dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@ApiTags('partners')
|
||||
@Controller('partners')
|
||||
export class PartnersController {
|
||||
constructor(
|
||||
private readonly partnersService: PartnersService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Register a new partner' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Partner registered successfully',
|
||||
type: PartnerResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 409, description: 'Email already exists' })
|
||||
async register(@Body() createPartnerDto: CreatePartnerDto) {
|
||||
return this.partnersService.register(createPartnerDto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Partner login' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Login successful',
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
async login(@Body() loginDto: LoginPartnerDto) {
|
||||
return this.authService.loginPartner(loginDto);
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get partner profile' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Partner profile retrieved',
|
||||
type: PartnerResponseDto,
|
||||
})
|
||||
async getProfile(@Request() req) {
|
||||
return this.partnersService.getPartner(req.user.partnerId);
|
||||
}
|
||||
|
||||
@Put('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update partner profile' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Profile updated successfully',
|
||||
type: PartnerResponseDto,
|
||||
})
|
||||
async updateProfile(
|
||||
@Request() req,
|
||||
@Body() updatePartnerDto: UpdatePartnerDto,
|
||||
) {
|
||||
return this.partnersService.updatePartner(
|
||||
req.user.partnerId,
|
||||
updatePartnerDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('callbacks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update callback URLs' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Callbacks updated successfully',
|
||||
})
|
||||
async updateCallbacks(
|
||||
@Request() req,
|
||||
@Body() updateCallbacksDto: UpdateCallbacksDto,
|
||||
) {
|
||||
return this.partnersService.updateCallbacks(
|
||||
req.user.partnerId,
|
||||
updateCallbacksDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('callbacks')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get callback configuration' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Callback configuration retrieved',
|
||||
})
|
||||
async getCallbacks(@Request() req) {
|
||||
return this.partnersService.getCallbacks(req.user.partnerId);
|
||||
}
|
||||
|
||||
@Post('keys/regenerate')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Regenerate API keys' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Keys regenerated successfully',
|
||||
})
|
||||
async regenerateKeys(@Request() req) {
|
||||
return this.partnersService.regenerateKeys(req.user.partnerId);
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get partner statistics' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Statistics retrieved',
|
||||
type: PartnerStatsResponseDto,
|
||||
})
|
||||
async getStatistics(@Request() req) {
|
||||
return this.partnersService.getPartnerStats(req.user.partnerId);
|
||||
}
|
||||
|
||||
@Get('statistics/dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get dashboard statistics' })
|
||||
@ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly', 'yearly'] })
|
||||
async getDashboardStats(
|
||||
@Request() req,
|
||||
@Query('period') period: string = 'monthly',
|
||||
) {
|
||||
return this.partnersService.getDashboardStats(req.user.partnerId, period);
|
||||
}
|
||||
|
||||
@Post('password/change')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Change password' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Password changed successfully',
|
||||
})
|
||||
async changePassword(
|
||||
@Request() req,
|
||||
@Body() changePasswordDto: ChangePasswordDto,
|
||||
) {
|
||||
return this.partnersService.changePassword(
|
||||
req.user.partnerId,
|
||||
changePasswordDto,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'List partner users' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
async listUsers(
|
||||
@Request() req,
|
||||
@Query('page') page: number = 1,
|
||||
@Query('limit') limit: number = 20,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.partnersService.listPartnerUsers(req.user.partnerId, {
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('users/:userId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get user details' })
|
||||
async getUserDetails(@Request() req, @Param('userId') userId: string) {
|
||||
return this.partnersService.getUserDetails(req.user.partnerId, userId);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get transaction history' })
|
||||
@ApiQuery({ name: 'startDate', required: false, type: Date })
|
||||
@ApiQuery({ name: 'endDate', required: false, type: Date })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async getTransactions(
|
||||
@Request() req,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('page') page: number = 1,
|
||||
@Query('limit') limit: number = 20,
|
||||
) {
|
||||
return this.partnersService.getTransactionHistory(req.user.partnerId, {
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('revenue')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get revenue analytics' })
|
||||
@ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly', 'yearly'] })
|
||||
@ApiQuery({ name: 'groupBy', required: false, enum: ['day', 'week', 'month', 'operator', 'country'] })
|
||||
async getRevenue(
|
||||
@Request() req,
|
||||
@Query('period') period: string = 'monthly',
|
||||
@Query('groupBy') groupBy: string = 'day',
|
||||
) {
|
||||
return this.partnersService.getRevenueAnalytics(req.user.partnerId, {
|
||||
period,
|
||||
groupBy,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('webhook/test')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Test webhook configuration' })
|
||||
async testWebhook(
|
||||
@Request() req,
|
||||
@Body() body: { url: string; event: string },
|
||||
) {
|
||||
return this.partnersService.testWebhook(
|
||||
req.user.partnerId,
|
||||
body.url,
|
||||
body.event,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get partner settings' })
|
||||
async getSettings(@Request() req) {
|
||||
return this.partnersService.getSettings(req.user.partnerId);
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update partner settings' })
|
||||
async updateSettings(@Request() req, @Body() settings: any) {
|
||||
return this.partnersService.updateSettings(req.user.partnerId, settings);
|
||||
}
|
||||
|
||||
@Get('api-usage')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get API usage statistics' })
|
||||
@ApiQuery({ name: 'period', required: false, enum: ['hourly', 'daily', 'weekly', 'monthly'] })
|
||||
async getApiUsage(
|
||||
@Request() req,
|
||||
@Query('period') period: string = 'daily',
|
||||
) {
|
||||
return this.partnersService.getApiUsageStats(req.user.partnerId, period);
|
||||
}
|
||||
|
||||
@Get('logs')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get activity logs' })
|
||||
@ApiQuery({ name: 'type', required: false })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async getActivityLogs(
|
||||
@Request() req,
|
||||
@Query('type') type?: string,
|
||||
@Query('page') page: number = 1,
|
||||
@Query('limit') limit: number = 50,
|
||||
) {
|
||||
return this.partnersService.getActivityLogs(req.user.partnerId, {
|
||||
type,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('account')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Delete partner account' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Account deletion initiated',
|
||||
})
|
||||
async deleteAccount(
|
||||
@Request() req,
|
||||
@Body() body: { password: string; reason?: string },
|
||||
) {
|
||||
return this.partnersService.deleteAccount(
|
||||
req.user.partnerId,
|
||||
body.password,
|
||||
body.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { PartnersController } from './partners.controller';
|
||||
import { PartnersService } from './partners.service';
|
||||
import { PrismaService } from '../../shared/services/prisma.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports:[AuthModule],
|
||||
controllers: [PartnersController],
|
||||
providers: [PartnersService, PrismaService],
|
||||
exports: [PartnersService],
|
||||
|
||||
@ -6,11 +6,53 @@ import {
|
||||
import { PrismaService } from '../../shared/services/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { CreatePartnerDto, UpdateCallbacksDto } from './dto/partner.dto';
|
||||
import { ChangePasswordDto, CreatePartnerDto, UpdateCallbacksDto, UpdatePartnerDto } from './dto/partner.dto';
|
||||
import { Prisma } from 'generated/prisma';
|
||||
|
||||
@Injectable()
|
||||
export class PartnersService {
|
||||
getActivityLogs(partnerId: any, arg1: { type: string | undefined; page: number; limit: number; }) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getApiUsageStats(partnerId: any, period: string) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getSettings(partnerId: any) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
updateSettings(partnerId: any, settings: any) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getTransactionHistory(partnerId: any, arg1: { startDate: Date | undefined; endDate: Date | undefined; page: number; limit: number; }) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
listPartnerUsers(partnerId: any, arg1: { page: number; limit: number; status: string | undefined; }) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
testWebhook(partnerId: any, url: string, event: string) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getRevenueAnalytics(partnerId: any, arg1: { period: string; groupBy: string; }) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getCallbacks(partnerId: any) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getUserDetails(partnerId: any, userId: string) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
changePassword(partnerId: any, changePasswordDto: ChangePasswordDto) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
getDashboardStats(partnerId: any, period: string) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
updatePartner(partnerId: any, updatePartnerDto: UpdatePartnerDto) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
deleteAccount(partnerId: any, password: string, reason: string | undefined) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async register(dto: CreatePartnerDto) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user