feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature

This commit is contained in:
diallolatoile 2025-11-17 17:28:16 +00:00
parent fb5eeb6a8c
commit 7091f1665d
12 changed files with 1216 additions and 4 deletions

View File

@ -0,0 +1,239 @@
// === ENUMS COHÉRENTS ===
export enum UserType {
HUB = 'HUB',
MERCHANT_PARTNER = 'MERCHANT'
}
export enum UserRole {
// Rôles Hub (sans merchantPartnerId)
DCB_ADMIN = 'dcb-admin',
DCB_SUPPORT = 'dcb-support',
DCB_PARTNER = 'dcb-partner',
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
}
export enum MerchantStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
PENDING = 'PENDING',
SUSPENDED = 'SUSPENDED'
}
export enum ConfigType {
API_KEY = 'API_KEY',
SECRET_KEY = 'SECRET_KEY',
WEBHOOK_URL = 'WEBHOOK_URL',
CALLBACK_URL = 'CALLBACK_URL',
TIMEOUT = 'TIMEOUT',
RETRY_COUNT = 'RETRY_COUNT',
CUSTOM = 'CUSTOM'
}
export enum Operator {
ORANGE_CI = 1,
MTN_CI = 2,
MOOV_CI = 3,
WAVE = 4
}
// === MODÈLES PRINCIPAUX ===
export interface MerchantConfig {
id?: number;
name: ConfigType | string;
value: string;
operatorId: Operator;
merchantId?: number;
createdAt?: string;
updatedAt?: string;
}
export interface TechnicalContact {
id?: number;
firstName: string;
lastName: string;
phone: string;
email: string;
merchantId?: number;
createdAt?: string;
updatedAt?: string;
}
export interface MerchantUser {
userId: string;
role: UserRole; // Utilisation de vos rôles existants
username?: string;
email?: string;
firstName?: string;
lastName?: string;
merchantPartnerId?: number;
}
export interface Merchant {
id?: number;
name: string;
logo?: string;
description?: string;
adresse: string;
phone: string;
status?: MerchantStatus;
configs: MerchantConfig[];
users: MerchantUser[];
technicalContacts: TechnicalContact[];
createdAt?: string;
updatedAt?: string;
createdBy?: string;
createdByUsername?: string;
}
// === DTOs CRUD ===
export interface CreateMerchantDto {
name: string;
logo?: string;
description?: string;
adresse: string;
phone: string;
configs: Omit<MerchantConfig, 'id' | 'merchantId' | 'createdAt' | 'updatedAt'>[];
technicalContacts: Omit<TechnicalContact, 'id' | 'merchantId' | 'createdAt' | 'updatedAt'>[];
}
export interface UpdateMerchantDto extends Partial<CreateMerchantDto> {
status?: MerchantStatus;
}
export interface AddUserToMerchantDto {
userId: string;
role: UserRole; // Utilisation de vos rôles existants
merchantPartnerId: number;
}
export interface UpdateUserRoleDto {
role: UserRole; // Utilisation de vos rôles existants
}
// === RÉPONSES API ===
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface MerchantStatsResponse {
totalMerchants: number;
activeMerchants: number;
inactiveMerchants: number;
pendingMerchants: number;
totalConfigs: number;
totalTechnicalContacts: number;
}
// === SEARCH ===
export interface SearchMerchantsParams {
query?: string;
status?: MerchantStatus;
page?: number;
limit?: number;
}
// === UTILITAIRES ===
export class MerchantUtils {
static getStatusDisplayName(status: MerchantStatus): string {
const statusNames = {
[MerchantStatus.ACTIVE]: 'Actif',
[MerchantStatus.INACTIVE]: 'Inactif',
[MerchantStatus.PENDING]: 'En attente',
[MerchantStatus.SUSPENDED]: 'Suspendu'
};
return statusNames[status] || status;
}
static getStatusBadgeClass(status: MerchantStatus): string {
const statusClasses = {
[MerchantStatus.ACTIVE]: 'badge bg-success',
[MerchantStatus.INACTIVE]: 'badge bg-secondary',
[MerchantStatus.PENDING]: 'badge bg-warning',
[MerchantStatus.SUSPENDED]: 'badge bg-danger'
};
return statusClasses[status] || 'badge bg-secondary';
}
static getOperatorName(operatorId: Operator): string {
const operatorNames = {
[Operator.ORANGE_CI]: 'Orange CI',
[Operator.MTN_CI]: 'MTN CI',
[Operator.MOOV_CI]: 'Moov CI',
[Operator.WAVE]: 'Wave'
};
return operatorNames[operatorId] || 'Inconnu';
}
static getConfigTypeName(configName: ConfigType | string): string {
const configTypeNames = {
[ConfigType.API_KEY]: 'Clé API',
[ConfigType.SECRET_KEY]: 'Clé Secrète',
[ConfigType.WEBHOOK_URL]: 'URL Webhook',
[ConfigType.CALLBACK_URL]: 'URL Callback',
[ConfigType.TIMEOUT]: 'Timeout (ms)',
[ConfigType.RETRY_COUNT]: 'Nombre de tentatives',
[ConfigType.CUSTOM]: 'Personnalisé'
};
return configTypeNames[configName as ConfigType] || configName;
}
static validateMerchantCreation(merchant: CreateMerchantDto): string[] {
const errors: string[] = [];
if (!merchant.name?.trim()) {
errors.push('Le nom du merchant est requis');
}
if (!merchant.adresse?.trim()) {
errors.push('L\'adresse est requise');
}
if (!merchant.phone?.trim()) {
errors.push('Le téléphone est requis');
}
if (!merchant.technicalContacts || merchant.technicalContacts.length === 0) {
errors.push('Au moins un contact technique est requis');
}
if (!merchant.configs || merchant.configs.length === 0) {
errors.push('Au moins une configuration est requise');
}
return errors;
}
// Méthode pour obtenir les rôles disponibles pour les merchants
static getAvailableMerchantRoles(): UserRole[] {
return [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
];
}
// Vérifier si un rôle est valide pour un merchant
static isValidMerchantRole(role: UserRole): boolean {
const merchantRoles = [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
];
return merchantRoles.includes(role);
}
}

View File

@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../../../../../dcb-user-service"
},
{
"path": "../../../.."
}
],
"settings": {}
}

View File

@ -117,7 +117,7 @@ export class MenuService {
}, },
{ label: 'Configurations', isTitle: true }, { label: 'Configurations', isTitle: true },
{ label: 'Merchant Configs', icon: 'lucideStore', url: '/merchant-configs' }, { label: 'Merchant Config', icon: 'lucideStore', url: '/merchant-config' },
{ label: 'Paramètres Système', icon: 'lucideSettings', url: '/settings' }, { label: 'Paramètres Système', icon: 'lucideSettings', url: '/settings' },
{ label: 'Intégrations Externes', icon: 'lucidePlug', url: '/integrations' }, { label: 'Intégrations Externes', icon: 'lucidePlug', url: '/integrations' },

View File

@ -76,7 +76,7 @@ export class PermissionsService {
// Settings - Tout le monde // Settings - Tout le monde
{ {
module: 'merchant-configs', module: 'merchant-config',
roles: this.allRoles roles: this.allRoles
}, },

View File

@ -126,7 +126,7 @@ export const menuItems: MenuItemType[] = [
// Paramètres & Intégrations // Paramètres & Intégrations
// --------------------------- // ---------------------------
{ label: 'Configurations', isTitle: true }, { label: 'Configurations', isTitle: true },
{ label: 'Merchant Configs', icon: 'lucideStore', url: '/merchant-configs' }, { label: 'Merchant Config', icon: 'lucideStore', url: '/merchant-config' },
{ label: 'Paramètres Système', icon: 'lucideSettings', url: '/settings' }, { label: 'Paramètres Système', icon: 'lucideSettings', url: '/settings' },
{ label: 'Intégrations Externes', icon: 'lucidePlug', url: '/integrations' }, { label: 'Intégrations Externes', icon: 'lucidePlug', url: '/integrations' },

View File

@ -0,0 +1,335 @@
<app-ui-card title="Configuration Merchant">
<span helper-text class="badge badge-soft-primary badge-label fs-xxs py-1">
Gestion des Merchants
</span>
<div class="ins-wizard" card-body>
<!-- Progress Bar -->
<ngb-progressbar
class="mb-4"
[value]="progressValue"
type="primary"
height="6px"
/>
<!-- Navigation Steps -->
<ul class="nav nav-tabs wizard-tabs" role="tablist">
@for (step of wizardSteps; track $index; let i = $index) {
<li class="nav-item">
<a
href="javascript:void(0);"
[class.active]="i === currentStep"
class="nav-link"
[class.disabled]="!isStepAccessible(i)"
[class.wizard-item-done]="i < currentStep"
(click)="goToStep(i)"
>
<span class="d-flex align-items-center">
<ng-icon [name]="step.icon" class="fs-32" />
<span class="flex-grow-1 ms-2 text-truncate">
<span class="mb-0 lh-base d-block fw-semibold text-body fs-base">
{{ step.title }}
</span>
<span class="mb-0 fw-normal">{{ step.subtitle }}</span>
</span>
</span>
</a>
</li>
}
</ul>
<!-- Messages -->
@if (configError) {
<div class="alert alert-danger mt-3">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ configError }}
</div>
}
@if (configSuccess) {
<div class="alert alert-success mt-3">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
{{ configSuccess }}
</div>
}
<!-- Contenu des Steps -->
<div class="tab-content pt-3">
@for (step of wizardSteps; track $index; let i = $index) {
<div
class="tab-pane fade"
[class.show]="currentStep === i"
[class.active]="currentStep === i"
>
<form [formGroup]="merchantForm">
<!-- Step 1: Informations de Base -->
@if (i === 0) {
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Nom du Merchant *</label>
<div formGroupName="basicInfo">
<input type="text" class="form-control" formControlName="name"
placeholder="Nom commercial" />
@if (basicInfo.get('name')?.invalid && basicInfo.get('name')?.touched) {
<div class="text-danger small">Le nom du merchant est requis</div>
}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Logo URL</label>
<div formGroupName="basicInfo">
<input type="url" class="form-control" formControlName="logo"
placeholder="https://..." />
</div>
</div>
<div class="col-12 mb-3">
<label class="form-label">Description</label>
<div formGroupName="basicInfo">
<textarea class="form-control" formControlName="description"
placeholder="Description du merchant..." rows="3"></textarea>
</div>
</div>
<div class="col-12 mb-3">
<label class="form-label">Adresse *</label>
<div formGroupName="basicInfo">
<input type="text" class="form-control" formControlName="adresse"
placeholder="Adresse complète" />
@if (basicInfo.get('adresse')?.invalid && basicInfo.get('adresse')?.touched) {
<div class="text-danger small">L'adresse est requise</div>
}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone *</label>
<div formGroupName="basicInfo">
<input type="tel" class="form-control" formControlName="phone"
placeholder="+225 XX XX XX XX" />
@if (basicInfo.get('phone')?.invalid && basicInfo.get('phone')?.touched) {
<div class="text-danger small">Le téléphone est requis et doit être valide</div>
}
</div>
</div>
</div>
}
<!-- Step 2: Contacts Techniques -->
@if (i === 1) {
<div class="row">
<div class="col-12 mb-4">
<div class="d-flex justify-content-between align-items-center">
<h6 class="border-bottom pb-2">Contacts Techniques</h6>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="addTechnicalContact()">
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Ajouter un contact
</button>
</div>
</div>
@for (contact of technicalContacts.controls; track $index; let idx = $index) {
<div class="col-12 mb-4 p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Contact #{{ idx + 1 }}</h6>
@if (technicalContacts.length > 1) {
<button type="button" class="btn btn-sm btn-outline-danger"
(click)="removeTechnicalContact(idx)">
<ng-icon name="lucideTrash2"></ng-icon>
</button>
}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Prénom *</label>
<input type="text" class="form-control"
[formControl]="getContactControl(contact, 'firstName')"
placeholder="Prénom" />
@if (getContactControl(contact, 'firstName').invalid && getContactControl(contact, 'firstName').touched) {
<div class="text-danger small">Le prénom est requis</div>
}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom *</label>
<input type="text" class="form-control"
[formControl]="getContactControl(contact, 'lastName')"
placeholder="Nom" />
@if (getContactControl(contact, 'lastName').invalid && getContactControl(contact, 'lastName').touched) {
<div class="text-danger small">Le nom est requis</div>
}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone *</label>
<input type="tel" class="form-control"
[formControl]="getContactControl(contact, 'phone')"
placeholder="+225 XX XX XX XX" />
@if (getContactControl(contact, 'phone').invalid && getContactControl(contact, 'phone').touched) {
<div class="text-danger small">Le téléphone est requis</div>
}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Email *</label>
<input type="email" class="form-control"
[formControl]="getContactControl(contact, 'email')"
placeholder="email@entreprise.com" />
@if (getContactControl(contact, 'email').invalid && getContactControl(contact, 'email').touched) {
<div class="text-danger small">L'email est requis et doit être valide</div>
}
</div>
</div>
</div>
}
</div>
}
<!-- Step 3: Configurations -->
@if (i === 2) {
<div class="row">
<div class="col-12 mb-4">
<div class="d-flex justify-content-between align-items-center">
<h6 class="border-bottom pb-2">Configurations Techniques</h6>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="addConfig()">
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Ajouter une configuration
</button>
</div>
</div>
@for (config of configs.controls; track $index; let idx = $index) {
<div class="col-12 mb-4 p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Configuration #{{ idx + 1 }}</h6>
@if (configs.length > 1) {
<button type="button" class="btn btn-sm btn-outline-danger"
(click)="removeConfig(idx)">
<ng-icon name="lucideTrash2"></ng-icon>
</button>
}
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Nom *</label>
<select class="form-select"
[formControl]="getConfigControl(config, 'name')">
<option value="">Sélectionnez un type</option>
@for (type of configTypes; track type.name) {
<option [value]="type.name">{{ type.label }}</option>
}
</select>
@if (getConfigControl(config, 'name').invalid && getConfigControl(config, 'name').touched) {
<div class="text-danger small">Le nom est requis</div>
}
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Valeur *</label>
<input type="text" class="form-control"
[formControl]="getConfigControl(config, 'value')"
placeholder="Valeur de configuration" />
@if (getConfigControl(config, 'value').invalid && getConfigControl(config, 'value').touched) {
<div class="text-danger small">La valeur est requise</div>
}
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Opérateur *</label>
<select class="form-select"
[formControl]="getConfigControl(config, 'operatorId')">
@for (operator of operators; track operator.id) {
<option [value]="operator.id">{{ operator.name }}</option>
}
</select>
@if (getConfigControl(config, 'operatorId').invalid && getConfigControl(config, 'operatorId').touched) {
<div class="text-danger small">L'opérateur est requis</div>
}
</div>
</div>
</div>
}
</div>
}
<!-- Step 4: Validation -->
@if (i === 4) {
<div class="row">
<div class="col-12">
<div class="alert alert-info">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
Vérifiez les informations avant de créer le merchant
</div>
<div class="card">
<div class="card-body">
<h6 class="card-title">Récapitulatif</h6>
<div class="row mb-4">
<div class="col-md-6">
<strong>Informations de Base:</strong><br>
<strong>Nom:</strong> {{ basicInfo.value.name || 'Non renseigné' }}<br>
<strong>Description:</strong> {{ basicInfo.value.description || 'Non renseigné' }}<br>
<strong>Adresse:</strong> {{ basicInfo.value.adresse || 'Non renseigné' }}<br>
<strong>Téléphone:</strong> {{ basicInfo.value.phone || 'Non renseigné' }}
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<strong>Contacts Techniques:</strong>
@for (contact of technicalContacts.value; track $index; let idx = $index) {
<div class="mt-2 p-2 bg-light rounded">
<strong>Contact #{{ idx + 1 }}:</strong>
{{ contact.firstName }} {{ contact.lastName }} -
{{ contact.phone }} - {{ contact.email }}
</div>
}
</div>
</div>
<div class="row">
<div class="col-12">
<strong>Configurations:</strong>
@for (config of configs.value; track $index; let idx = $index) {
<div class="mt-2 p-2 bg-light rounded">
<strong>Configuration #{{ idx + 1 }}:</strong>
{{ getConfigTypeName(config.name) }} = {{ config.value }}
({{ getOperatorName(config.operatorId) }})
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
}
</form>
<!-- Navigation Buttons -->
<div class="d-flex justify-content-between mt-4">
@if (i > 0) {
<button type="button" class="btn btn-secondary" (click)="previousStep()">
← Précédent
</button>
} @else {
<div></div>
}
@if (i < wizardSteps.length - 1) {
<button type="button" class="btn btn-primary" (click)="nextStep()"
[disabled]="!isStepValid(i)">
Suivant →
</button>
} @else {
<button type="button" class="btn btn-success"
(click)="submitForm()" [disabled]="configLoading">
@if (configLoading) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Créer le Merchant
</button>
}
</div>
</div>
}
</div>
</div>
</app-ui-card>

View File

@ -0,0 +1,315 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable, map, catchError, throwError } from 'rxjs';
// Import de vos modèles existants
import { UserRole } from '@core/models/dcb-bo-hub-user.model';
import {
Merchant,
CreateMerchantDto,
UpdateMerchantDto,
MerchantUser,
AddUserToMerchantDto,
UpdateUserRoleDto,
MerchantConfig,
TechnicalContact,
ApiResponse,
PaginatedResponse,
MerchantStatsResponse,
SearchMerchantsParams,
MerchantStatus
} from '@core/models/merchant-config.model';
@Injectable({ providedIn: 'root' })
export class MerchantConfigService {
private http = inject(HttpClient);
private baseApiUrl = `${environment.configApiUrl}/merchants`;
// Merchant CRUD Operations
createMerchant(createMerchantDto: CreateMerchantDto): Observable<Merchant> {
return this.http.post<ApiResponse<Merchant>>(this.baseApiUrl, createMerchantDto).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to create merchant');
}
return response.data!;
}),
catchError(error => {
console.error('Error creating merchant:', error);
return throwError(() => error);
})
);
}
getMerchants(page: number = 1, limit: number = 10, params?: SearchMerchantsParams): Observable<PaginatedResponse<Merchant>> {
let httpParams = new HttpParams()
.set('page', page.toString())
.set('limit', limit.toString());
if (params?.query) {
httpParams = httpParams.set('query', params.query);
}
if (params?.status) {
httpParams = httpParams.set('status', params.status);
}
return this.http.get<ApiResponse<PaginatedResponse<Merchant>>>(this.baseApiUrl, { params: httpParams }).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to load merchants');
}
return response.data!;
}),
catchError(error => {
console.error('Error loading merchants:', error);
return throwError(() => error);
})
);
}
getMerchantById(id: number): Observable<Merchant> {
return this.http.get<ApiResponse<Merchant>>(`${this.baseApiUrl}/${id}`).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to load merchant');
}
return response.data!;
}),
catchError(error => {
console.error(`Error loading merchant ${id}:`, error);
return throwError(() => error);
})
);
}
updateMerchant(id: number, updateMerchantDto: UpdateMerchantDto): Observable<Merchant> {
return this.http.patch<ApiResponse<Merchant>>(`${this.baseApiUrl}/${id}`, updateMerchantDto).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to update merchant');
}
return response.data!;
}),
catchError(error => {
console.error(`Error updating merchant ${id}:`, error);
return throwError(() => error);
})
);
}
deleteMerchant(id: number): Observable<void> {
return this.http.delete<ApiResponse<void>>(`${this.baseApiUrl}/${id}`).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to delete merchant');
}
}),
catchError(error => {
console.error(`Error deleting merchant ${id}:`, error);
return throwError(() => error);
})
);
}
// User Management
addUserToMerchant(addUserDto: AddUserToMerchantDto): Observable<MerchantUser> {
return this.http.post<ApiResponse<MerchantUser>>(`${this.baseApiUrl}/users`, addUserDto).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to add user to merchant');
}
return response.data!;
}),
catchError(error => {
console.error('Error adding user to merchant:', error);
return throwError(() => error);
})
);
}
getMerchantUsers(merchantId: number): Observable<MerchantUser[]> {
return this.http.get<ApiResponse<MerchantUser[]>>(`${this.baseApiUrl}/${merchantId}/users`).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to load merchant users');
}
return response.data!;
}),
catchError(error => {
console.error(`Error loading users for merchant ${merchantId}:`, error);
return throwError(() => error);
})
);
}
updateUserRole(merchantId: number, userId: string, updateRoleDto: UpdateUserRoleDto): Observable<MerchantUser> {
return this.http.patch<ApiResponse<MerchantUser>>(
`${this.baseApiUrl}/${merchantId}/users/${userId}/role`,
updateRoleDto
).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to update user role');
}
return response.data!;
}),
catchError(error => {
console.error(`Error updating user role for merchant ${merchantId}, user ${userId}:`, error);
return throwError(() => error);
})
);
}
removeUserFromMerchant(merchantId: number, userId: string): Observable<void> {
return this.http.delete<ApiResponse<void>>(`${this.baseApiUrl}/${merchantId}/users/${userId}`).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to remove user from merchant');
}
}),
catchError(error => {
console.error(`Error removing user ${userId} from merchant ${merchantId}:`, error);
return throwError(() => error);
})
);
}
getUserMerchants(userId: string): Observable<Merchant[]> {
return this.http.get<ApiResponse<Merchant[]>>(`${this.baseApiUrl}/user/${userId}`).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to load user merchants');
}
return response.data!;
}),
catchError(error => {
console.error(`Error loading merchants for user ${userId}:`, error);
return throwError(() => error);
})
);
}
// Config Management
addConfigToMerchant(merchantId: number, config: Omit<MerchantConfig, 'id' | 'merchantId'>): Observable<MerchantConfig> {
return this.http.post<ApiResponse<MerchantConfig>>(`${this.baseApiUrl}/${merchantId}/configs`, config).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to add config to merchant');
}
return response.data!;
}),
catchError(error => {
console.error(`Error adding config to merchant ${merchantId}:`, error);
return throwError(() => error);
})
);
}
updateConfig(configId: number, config: Partial<MerchantConfig>): Observable<MerchantConfig> {
return this.http.patch<ApiResponse<MerchantConfig>>(`${this.baseApiUrl}/configs/${configId}`, config).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to update config');
}
return response.data!;
}),
catchError(error => {
console.error(`Error updating config ${configId}:`, error);
return throwError(() => error);
})
);
}
deleteConfig(configId: number): Observable<void> {
return this.http.delete<ApiResponse<void>>(`${this.baseApiUrl}/configs/${configId}`).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to delete config');
}
}),
catchError(error => {
console.error(`Error deleting config ${configId}:`, error);
return throwError(() => error);
})
);
}
// Technical Contacts Management
addTechnicalContact(merchantId: number, contact: Omit<TechnicalContact, 'id' | 'merchantId'>): Observable<TechnicalContact> {
return this.http.post<ApiResponse<TechnicalContact>>(`${this.baseApiUrl}/${merchantId}/contacts`, contact).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to add technical contact');
}
return response.data!;
}),
catchError(error => {
console.error(`Error adding technical contact to merchant ${merchantId}:`, error);
return throwError(() => error);
})
);
}
updateTechnicalContact(contactId: number, contact: Partial<TechnicalContact>): Observable<TechnicalContact> {
return this.http.patch<ApiResponse<TechnicalContact>>(`${this.baseApiUrl}/contacts/${contactId}`, contact).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to update technical contact');
}
return response.data!;
}),
catchError(error => {
console.error(`Error updating technical contact ${contactId}:`, error);
return throwError(() => error);
})
);
}
deleteTechnicalContact(contactId: number): Observable<void> {
return this.http.delete<ApiResponse<void>>(`${this.baseApiUrl}/contacts/${contactId}`).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to delete technical contact');
}
}),
catchError(error => {
console.error(`Error deleting technical contact ${contactId}:`, error);
return throwError(() => error);
})
);
}
// Statistics
getMerchantStats(): Observable<MerchantStatsResponse> {
return this.http.get<ApiResponse<MerchantStatsResponse>>(`${this.baseApiUrl}/stats`).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to load merchant statistics');
}
return response.data!;
}),
catchError(error => {
console.error('Error loading merchant statistics:', error);
return throwError(() => error);
})
);
}
// Status Management
updateMerchantStatus(merchantId: number, status: MerchantStatus): Observable<Merchant> {
return this.http.patch<ApiResponse<Merchant>>(`${this.baseApiUrl}/${merchantId}/status`, { status }).pipe(
map(response => {
if (!response.success) {
throw new Error(response.error || 'Failed to update merchant status');
}
return response.data!;
}),
catchError(error => {
console.error(`Error updating status for merchant ${merchantId}:`, error);
return throwError(() => error);
})
);
}
}

View File

@ -0,0 +1,294 @@
import { ChangeDetectorRef, Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup, FormControl } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
import { UiCard } from '@app/components/ui-card';
import { MerchantConfigService } from './merchant-config.service';
import { CreateMerchantDto, MerchantConfig as MerchantConfigModel, TechnicalContact, MerchantUtils, ConfigType, Operator } from '@core/models/merchant-config.model';
import { UserRole } from '@core/models/dcb-bo-hub-user.model';
import { firstValueFrom, Subject } from 'rxjs';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
@Component({
selector: 'app-merchant-config',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
NgIcon,
NgbProgressbarModule,
UiCard
],
templateUrl: './merchant-config.html'
})
export class MerchantConfig implements OnInit {
private fb = inject(FormBuilder);
private merchantConfigService = inject(MerchantConfigService);
protected roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
// Configuration wizard
currentStep = 0;
wizardSteps = [
{ id: 'basic-info', icon: 'lucideBuilding', title: 'Informations de Base', subtitle: 'Détails du merchant' },
{ id: 'technical-contacts', icon: 'lucideUsers', title: 'Contacts Techniques', subtitle: 'Personnes de contact' },
{ id: 'configurations', icon: 'lucideSettings', title: 'Configurations', subtitle: 'Paramètres techniques' },
{ id: 'review', icon: 'lucideCheckCircle', title: 'Validation', subtitle: 'Vérification finale' }
];
configLoading = false;
configError = '';
configSuccess = '';
// Formulaires
merchantForm = this.fb.group({
basicInfo: this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
logo: [''],
description: [''],
adresse: ['', [Validators.required]],
phone: ['', [Validators.required, Validators.pattern(/^\+?[0-9\s\-\(\)]+$/)]]
}),
technicalContacts: this.fb.array([]),
configs: this.fb.array([])
});
// Opérateurs disponibles
operators = [
{ id: Operator.ORANGE_CI, name: 'Orange CI' },
{ id: Operator.MTN_CI, name: 'MTN CI' },
{ id: Operator.MOOV_CI, name: 'Moov CI' },
{ id: Operator.WAVE, name: 'Wave' }
];
// Types de configuration
configTypes = [
{ name: ConfigType.API_KEY, label: 'Clé API' },
{ name: ConfigType.SECRET_KEY, label: 'Clé Secrète' },
{ name: ConfigType.WEBHOOK_URL, label: 'URL Webhook' },
{ name: ConfigType.CALLBACK_URL, label: 'URL Callback' },
{ name: ConfigType.TIMEOUT, label: 'Timeout (ms)' },
{ name: ConfigType.RETRY_COUNT, label: 'Nombre de tentatives' },
{ name: ConfigType.CUSTOM, label: 'Personnalisé' }
];
// Rôles disponibles pour les merchants (utilisation de vos rôles existants)
availableMerchantRoles = MerchantUtils.getAvailableMerchantRoles();
ngOnInit() {
// Ajouter un contact technique par défaut
this.addTechnicalContact();
// Ajouter une configuration par défaut
this.addConfig();
}
// Navigation du wizard
get progressValue(): number {
return ((this.currentStep + 1) / this.wizardSteps.length) * 100;
}
nextStep() {
if (this.currentStep < this.wizardSteps.length - 1 && this.isStepValid(this.currentStep)) {
this.currentStep++;
}
}
previousStep() {
if (this.currentStep > 0) {
this.currentStep--;
}
}
goToStep(index: number) {
if (this.isStepAccessible(index)) {
this.currentStep = index;
}
}
isStepAccessible(index: number): boolean {
if (index === 0) return true;
for (let i = 0; i < index; i++) {
if (!this.isStepValid(i)) {
return false;
}
}
return true;
}
isStepValid(stepIndex: number): boolean {
switch (stepIndex) {
case 0: // Basic Info
return this.basicInfo.valid;
case 1: // Technical Contacts
return this.technicalContacts.valid && this.technicalContacts.length > 0;
case 2: // Configurations
return this.configs.valid;
case 3: // Review
return this.merchantForm.valid;
default:
return false;
}
}
// Gestion des contacts techniques
get technicalContacts(): FormArray {
return this.merchantForm.get('technicalContacts') as FormArray;
}
addTechnicalContact() {
const contactGroup = this.fb.group({
firstName: ['', [Validators.required]],
lastName: ['', [Validators.required]],
phone: ['', [Validators.required, Validators.pattern(/^\+?[0-9\s\-\(\)]+$/)]],
email: ['', [Validators.required, Validators.email]]
});
this.technicalContacts.push(contactGroup);
}
removeTechnicalContact(index: number) {
if (this.technicalContacts.length > 1) {
this.technicalContacts.removeAt(index);
}
}
getContactControl(contact: any, field: string): FormControl {
return contact.get(field) as FormControl;
}
// Gestion des configurations
get configs(): FormArray {
return this.merchantForm.get('configs') as FormArray;
}
addConfig() {
const configGroup = this.fb.group({
name: ['', [Validators.required]],
value: ['', [Validators.required]],
operatorId: [Operator.ORANGE_CI, [Validators.required]]
});
this.configs.push(configGroup);
}
removeConfig(index: number) {
if (this.configs.length > 1) {
this.configs.removeAt(index);
}
}
getConfigControl(config: any, field: string): FormControl {
return config.get(field) as FormControl;
}
// Soumission du formulaire
async submitForm() {
if (this.merchantForm.valid) {
this.configLoading = true;
this.configError = '';
try {
const formData = this.merchantForm.value;
const createMerchantDto: CreateMerchantDto = {
name: this.safeString(formData.basicInfo?.name),
logo: this.safeString(formData.basicInfo?.logo),
description: this.safeString(formData.basicInfo?.description),
adresse: this.safeString(formData.basicInfo?.adresse),
phone: this.safeString(formData.basicInfo?.phone),
technicalContacts: (formData.technicalContacts || []).map((contact: any) => ({
firstName: this.safeString(contact.firstName),
lastName: this.safeString(contact.lastName),
phone: this.safeString(contact.phone),
email: this.safeString(contact.email)
})),
configs: (formData.configs || []).map((config: any) => ({
name: this.safeString(config.name),
value: this.safeString(config.value),
operatorId: this.safeNumber(config.operatorId)
}))
};
// Validation avant envoi
const validationErrors = MerchantUtils.validateMerchantCreation(createMerchantDto);
if (validationErrors.length > 0) {
this.configError = validationErrors.join(', ');
return;
}
const response = await firstValueFrom(
this.merchantConfigService.createMerchant(createMerchantDto)
);
this.configSuccess = `Merchant créé avec succès! ID: ${response.id}`;
this.merchantForm.reset();
this.currentStep = 0;
// Réinitialiser les tableaux
this.technicalContacts.clear();
this.configs.clear();
this.addTechnicalContact();
this.addConfig();
} catch (error: any) {
this.configError = error.message || 'Erreur lors de la création du merchant';
console.error('Error creating merchant:', error);
} finally {
this.configLoading = false;
}
} else {
this.configError = 'Veuillez corriger les erreurs dans le formulaire';
this.markAllFieldsAsTouched();
}
}
// Méthodes utilitaires
private safeString(value: string | null | undefined): string {
return value || '';
}
private safeNumber(value: number | null | undefined): number {
return value || 0;
}
private markAllFieldsAsTouched() {
Object.keys(this.merchantForm.controls).forEach(key => {
const control = this.merchantForm.get(key);
if (control instanceof FormGroup) {
Object.keys(control.controls).forEach(subKey => {
control.get(subKey)?.markAsTouched();
});
} else if (control instanceof FormArray) {
control.controls.forEach((arrayControl: any) => {
if (arrayControl instanceof FormGroup) {
Object.keys(arrayControl.controls).forEach(subKey => {
arrayControl.get(subKey)?.markAsTouched();
});
}
});
} else {
control?.markAsTouched();
}
});
}
// Getters pour les formulaires
get basicInfo() {
return this.merchantForm.get('basicInfo') as FormGroup;
}
// Méthodes pour le template
getOperatorName(operatorId: Operator): string {
return MerchantUtils.getOperatorName(operatorId);
}
getConfigTypeName(configName: string): string {
return MerchantUtils.getConfigTypeName(configName);
}
getRoleLabel(role: UserRole): string {
return this.roleService.getRoleLabel(role);
}
}

View File

@ -23,6 +23,7 @@ import { Help } from '@modules/help/help';
import { About } from '@modules/about/about'; import { About } from '@modules/about/about';
import { SubscriptionsManagement } from './subscriptions/subscriptions'; import { SubscriptionsManagement } from './subscriptions/subscriptions';
import { SubscriptionPayments } from './subscriptions/subscription-payments/subscription-payments'; import { SubscriptionPayments } from './subscriptions/subscription-payments/subscription-payments';
import { MerchantConfig } from './merchant-config/merchant-config';
const routes: Routes = [ const routes: Routes = [
// --------------------------- // ---------------------------
@ -164,7 +165,7 @@ const routes: Routes = [
}, },
// --------------------------- // ---------------------------
// Partners (existant - gardé pour référence) // Partners
// --------------------------- // ---------------------------
{ {
path: 'merchant-users-management', path: 'merchant-users-management',
@ -184,6 +185,20 @@ const routes: Routes = [
] ]
} }
}, },
// ---------------------------
// Merchant Config
// ---------------------------
{
path: 'merchant-config',
component: MerchantConfig,
canActivate: [authGuard, roleGuard],
data: {
title: 'Merchant Config',
module: 'merchant-config'
}
},
// --------------------------- // ---------------------------
// Operators (Admin seulement) // Operators (Admin seulement)
// --------------------------- // ---------------------------

View File

@ -2,5 +2,6 @@ export const environment = {
production: true, production: true,
localServiceTestApiUrl: "https://backoffice.dcb.pixpay.sn/api/v1", localServiceTestApiUrl: "https://backoffice.dcb.pixpay.sn/api/v1",
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1", iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1', apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
}; };

View File

@ -2,5 +2,6 @@ export const environment = {
production: true, production: true,
localServiceTestApiUrl: "https://backoffice.dcb.pixpay.sn/api/v1", localServiceTestApiUrl: "https://backoffice.dcb.pixpay.sn/api/v1",
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1", iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1', apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
}; };

View File

@ -2,5 +2,6 @@ export const environment = {
production: false, production: false,
localServiceTestApiUrl: "http://localhost:4200/api/v1", localServiceTestApiUrl: "http://localhost:4200/api/v1",
iamApiUrl: "http://localhost:3000/api/v1", iamApiUrl: "http://localhost:3000/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1', apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
} }