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-22 17:39:19 +00:00
parent a4834002df
commit 10a272fb85
9 changed files with 2809 additions and 1206 deletions

View File

@ -116,13 +116,12 @@ export interface ApiMerchant {
adresse: string;
phone: string;
configs: ApiMerchantConfig[];
merchantUsers: ApiMerchantUser[];
users: ApiMerchantUser[];
technicalContacts: ApiTechnicalContact[];
createdAt?: string;
updatedAt?: string;
}
// === DTOs CRUD ===
// === DTOs CRUD ===
export interface CreateMerchantDto {
name: string;
@ -153,9 +152,6 @@ export interface UpdateUserRoleDto {
role: UserRole;
}
export interface UpdateUserRoleDto {
role: UserRole; // Utilisation de vos rôles existants
}
// === RÉPONSES API ===
export interface ApiResponse<T> {

View File

@ -43,10 +43,15 @@ export class RoleManagementService {
}))
})),
tap(roles => this.availableRoles$.next(roles)),
catchError(error => this.getDefaultHubRoles(error))
catchError(error => {
console.error('Error loading hub roles:', error);
// On renvoie un observable valide pour éviter le crash
return of({ roles: [] } as AvailableRolesWithPermissions);
})
);
}
/**
* Charge les rôles Marchands disponibles
*/
@ -59,56 +64,14 @@ export class RoleManagementService {
}))
})),
tap(roles => this.availableRoles$.next(roles)),
catchError(error => this.getDefaultMerchantRoles(error))
catchError(error => {
console.error('Error loading merchant roles:', error);
// On renvoie un observable valide pour éviter le crash
return of({ roles: [] } as AvailableRolesWithPermissions);
})
);
}
/**
* Rôles Hub par défaut en cas d'erreur
*/
private getDefaultHubRoles(error?: any): Observable<AvailableRolesWithPermissions> {
console.error('Error loading hub roles:', error);
const defaultRoles: AvailableRolesWithPermissions = {
roles: [
UserRole.DCB_ADMIN,
UserRole.DCB_SUPPORT,
UserRole.DCB_PARTNER
].map(role => ({
value: role,
label: this.getRoleLabel(role),
description: this.getRoleDescription(role),
allowedForCreation: true,
userType: UserType.HUB,
permissions: this.getPermissionsForRole(role)
}))
};
this.availableRoles$.next(defaultRoles);
return of(defaultRoles);
}
/**
* Rôles Marchands par défaut en cas d'erreur
*/
private getDefaultMerchantRoles(error?: any): Observable<AvailableRolesWithPermissions> {
console.error('Error loading merchant roles:', error);
const defaultRoles: AvailableRolesWithPermissions = {
roles: [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
].map(role => ({
value: role,
label: this.getRoleLabel(role),
description: this.getRoleDescription(role),
allowedForCreation: true,
userType: UserType.MERCHANT_PARTNER,
permissions: this.getPermissionsForRole(role)
}))
};
this.availableRoles$.next(defaultRoles);
return of(defaultRoles);
}
/**
* Définit le rôle de l'utilisateur courant
*/
@ -192,10 +155,10 @@ export class RoleManagementService {
canDeleteUsers: true,
canManageRoles: true,
canViewStats: true,
canManageMerchants: true,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: true,
canAccessPartner: false,
assignableRoles: [UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]
};

View File

@ -51,9 +51,7 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
@Output() openCreateMerchantModal = new EventEmitter<void>();
@Output() editMerchantRequested = new EventEmitter<Merchant>();
@Output() deleteMerchantRequested = new EventEmitter<Merchant>();
@Output() activateMerchantRequested = new EventEmitter<Merchant>();
@Output() deactivateMerchantRequested = new EventEmitter<Merchant>();
// Données
allMerchants: Merchant[] = [];
filteredMerchants: Merchant[] = [];
@ -259,14 +257,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
this.deleteMerchantRequested.emit(merchant);
}
activateMerchant(merchant: Merchant) {
this.activateMerchantRequested.emit(merchant);
}
deactivateMerchant(merchant: Merchant) {
this.deactivateMerchantRequested.emit(merchant);
}
// ==================== FILTRES ET RECHERCHE ====================
onSearch() {

View File

@ -31,61 +31,21 @@
}
</small>
</div>
@if (canCreateMerchants) {
<button
class="btn btn-primary btn-sm"
(click)="openCreateMerchantModal()"
>
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Nouveau Marchand
</button>
}
</div>
</div>
</div>
</div>
}
<!-- Sélection du marchand pour les ADMIN -->
@if (showMerchantPartnerField) {
<!-- Message de succès -->
@if (successMessage) {
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-body py-2">
<div class="row align-items-center">
<div class="col-md-4">
<label class="form-label mb-0">
<strong>Filtrer par Marchand :</strong>
</label>
</div>
<div class="col-md-6">
<select
class="form-select form-select-sm"
[(ngModel)]="selectedMerchantPartnerId"
(change)="onMerchantSelectionChange(selectedMerchantPartnerId)"
>
<option value="">Tous les marchands</option>
@for (partner of merchantPartners; track partner.id) {
<option [value]="partner.merchantPartnerId || partner.id">
{{ partner.username }}
@if (partner.firstName || partner.lastName) {
- {{ partner.firstName }} {{ partner.lastName }}
}
@if (!partner.enabled) {
<span class="badge bg-warning ms-1">Inactif</span>
}
</option>
}
</select>
</div>
<div class="col-md-2">
@if (loadingMerchantPartners) {
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
</div>
</div>
<div class="alert alert-success alert-dismissible fade show">
<div class="d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ successMessage }}</div>
<button type="button" class="btn-close ms-auto" (click)="successMessage = ''"></button>
</div>
</div>
</div>
@ -107,8 +67,7 @@
<ng-icon name="lucideList" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Marchands</span>
</a>
<ng-template ngbNavContent>
<!-- COMPOSANT CORRIGÉ avec les bonnes propriétés -->
<ng-template ngbNavContent>
<app-merchant-config-list
#merchantConfigsList
[canCreateMerchants]="canCreateMerchants"
@ -130,6 +89,8 @@
@if (selectedMerchantId) {
<app-merchant-config-view
[merchantId]="selectedMerchantId"
(openCreateMerchantModal)="openCreateMerchantModal()"
(editMerchantRequested)="onEditMerchantRequested($event)"
(editConfigRequested)="onEditConfigRequested($event)"
(back)="backToList()"
/>
@ -511,7 +472,16 @@
@if (selectedMerchantForEdit) {
<form (ngSubmit)="updateMerchant()" #editForm="ngForm">
<div class="row g-3">
<!-- INFORMATIONS GÉNÉRALES -->
<div class="row g-3 mb-4">
<div class="col-12">
<h6 class="border-bottom pb-2 text-primary">
<ng-icon name="lucideBuilding" class="me-2"></ng-icon>
Informations Générales
</h6>
</div>
<div class="col-md-6">
<label class="form-label">Nom du marchand <span class="text-danger">*</span></label>
<input
@ -521,6 +491,7 @@
name="name"
required
[disabled]="updatingMerchant"
placeholder="Ex: Boutique ABC"
>
</div>
@ -532,6 +503,7 @@
[(ngModel)]="selectedMerchantForEdit.logo"
name="logo"
[disabled]="updatingMerchant"
placeholder="https://exemple.com/logo.png"
>
</div>
@ -543,6 +515,7 @@
name="description"
[disabled]="updatingMerchant"
rows="2"
placeholder="Description du marchand"
></textarea>
</div>
@ -555,6 +528,7 @@
name="adresse"
required
[disabled]="updatingMerchant"
placeholder="Adresse complète"
>
</div>
@ -567,12 +541,226 @@
name="phone"
required
[disabled]="updatingMerchant"
placeholder="+XX X XX XX XX XX"
>
</div>
</div>
<div class="modal-footer mt-4">
<!-- CONFIGURATIONS TECHNIQUES -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center border-bottom pb-2">
<h6 class="mb-0 text-primary">
<ng-icon name="lucideSettings" class="me-2"></ng-icon>
Configurations Techniques
</h6>
<button
type="button"
class="btn btn-outline-primary btn-sm"
(click)="addConfigInEdit()"
[disabled]="updatingMerchant"
>
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Ajouter une configuration
</button>
</div>
</div>
@if (!selectedMerchantForEdit.configs || selectedMerchantForEdit.configs.length === 0) {
<div class="col-12">
<div class="alert alert-warning">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Au moins une configuration est requise
</div>
</div>
}
<!-- Liste des configurations -->
@for (config of selectedMerchantForEdit.configs; track trackByConfigId($index, config); let i = $index) {
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<ng-icon [name]="getConfigTypeIconSafe(config.name)" class="me-2 text-primary"></ng-icon>
<span class="fw-semibold">Configuration {{ i + 1 }}</span>
</div>
@if (selectedMerchantForEdit.configs.length > 1) {
<button
type="button"
class="btn btn-sm btn-outline-danger"
(click)="removeConfigInEdit(i)"
[disabled]="updatingMerchant"
>
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer
</button>
}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Type <span class="text-danger">*</span></label>
<select
class="form-select"
[(ngModel)]="config.name"
[name]="'editConfigType_' + i"
required
[disabled]="updatingMerchant"
>
<option value="" disabled>Sélectionnez un type</option>
@for (type of configTypes; track type.name) {
<option [value]="type.name">{{ type.label }}</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Opérateur <span class="text-danger">*</span></label>
<select
class="form-select"
[(ngModel)]="config.operatorId"
[name]="'editOperatorId_' + i"
required
[disabled]="updatingMerchant"
>
<option value="" disabled>Sélectionnez un opérateur</option>
@for (operator of operators; track operator.id) {
<option [value]="operator.id">{{ operator.name }}</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label">Valeur <span class="text-danger">*</span></label>
<textarea
class="form-control font-monospace"
[(ngModel)]="config.value"
[name]="'editValue_' + i"
required
[disabled]="updatingMerchant"
rows="3"
placeholder="Valeur de configuration"
></textarea>
@if (isSensitiveConfig(config)) {
<div class="form-text text-warning">
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Cette configuration contient des informations sensibles
</div>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
<!-- CONTACTS TECHNIQUES -->
<div class="row g-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center border-bottom pb-2">
<h6 class="mb-0 text-primary">
<ng-icon name="lucideUsers" class="me-2"></ng-icon>
Contacts Techniques
</h6>
<button
type="button"
class="btn btn-outline-primary btn-sm"
(click)="addTechnicalContactInEdit()"
[disabled]="updatingMerchant"
>
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Ajouter un contact
</button>
</div>
</div>
@if (!selectedMerchantForEdit.technicalContacts || selectedMerchantForEdit.technicalContacts.length === 0) {
<div class="col-12">
<div class="alert alert-warning">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Au moins un contact technique est requis
</div>
</div>
}
<!-- Liste des contacts techniques -->
@for (contact of selectedMerchantForEdit.technicalContacts; track trackByContactId($index, contact); let i = $index) {
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<ng-icon name="lucideUser" class="me-2 text-primary"></ng-icon>
<span class="fw-semibold">Contact {{ i + 1 }}</span>
</div>
@if (selectedMerchantForEdit.technicalContacts.length > 1) {
<button
type="button"
class="btn btn-sm btn-outline-danger"
(click)="removeTechnicalContactInEdit(i)"
[disabled]="updatingMerchant"
>
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer
</button>
}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Prénom <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.firstName"
[name]="'editFirstName_' + i"
required
[disabled]="updatingMerchant"
placeholder="Prénom"
>
</div>
<div class="col-md-6">
<label class="form-label">Nom <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.lastName"
[name]="'editLastName_' + i"
required
[disabled]="updatingMerchant"
placeholder="Nom"
>
</div>
<div class="col-md-6">
<label class="form-label">Téléphone <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.phone"
[name]="'editPhone_' + i"
required
[disabled]="updatingMerchant"
placeholder="+XX X XX XX XX XX"
>
</div>
<div class="col-md-6">
<label class="form-label">Email <span class="text-danger">*</span></label>
<input
type="email"
class="form-control"
[(ngModel)]="contact.email"
[name]="'editEmail_' + i"
required
[disabled]="updatingMerchant"
placeholder="email@exemple.com"
>
</div>
</div>
</div>
</div>
</div>
}
</div>
<div class="modal-footer mt-4 border-top pt-3">
<button
type="button"
class="btn btn-light"
@ -585,7 +773,9 @@
<button
type="submit"
class="btn btn-primary"
[disabled]="!editForm.form.valid || updatingMerchant"
[disabled]="!editForm.form.valid || updatingMerchant ||
!selectedMerchantForEdit.technicalContacts.length ||
!selectedMerchantForEdit.configs.length"
>
@if (updatingMerchant) {
<div class="spinner-border spinner-border-sm me-2" role="status">
@ -594,7 +784,7 @@
Mise à jour...
} @else {
<ng-icon name="lucideSave" class="me-1"></ng-icon>
Enregistrer
Enregistrer les modifications
}
</button>
</div>
@ -690,4 +880,4 @@
}
</button>
</div>
</ng-template>
</ng-template>

View File

@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable, map, catchError, throwError } from 'rxjs';
import { Observable, map, catchError, throwError, retry, timeout } from 'rxjs';
// Import de vos modèles existants
import { UserRole } from '@core/models/dcb-bo-hub-user.model';
@ -25,24 +25,33 @@ import {
ApiMerchantUser
} from '@core/models/merchant-config.model';
// SERVICE DE CONVERSION
import { MerchantDataAdapter } from './merchant-data-adapter.service';
@Injectable({ providedIn: 'root' })
export class MerchantConfigService {
private http = inject(HttpClient);
private dataAdapter = inject(MerchantDataAdapter);
private baseApiUrl = `${environment.configApiUrl}/merchants`;
// Merchant CRUD Operations
private readonly REQUEST_TIMEOUT = 30000;
private readonly MAX_RETRIES = 2;
// ==================== MERCHANT CRUD OPERATIONS ====================
createMerchant(createMerchantDto: CreateMerchantDto): Observable<Merchant> {
return this.http.post<ApiMerchant>(this.baseApiUrl, createMerchantDto).pipe(
const apiDto = this.dataAdapter.convertCreateMerchantToApi(createMerchantDto);
console.log('📤 Creating merchant:', apiDto);
return this.http.post<ApiMerchant>(this.baseApiUrl, apiDto).pipe(
timeout(this.REQUEST_TIMEOUT),
retry(this.MAX_RETRIES),
map(apiMerchant => {
console.log('Merchant created successfully:', apiMerchant);
return this.mapApiMerchantToMerchant(apiMerchant);
console.log('Merchant created successfully:', apiMerchant);
return this.dataAdapter.convertApiMerchantToFrontend(apiMerchant);
}),
catchError(error => {
console.error('Error creating merchant:', error);
return throwError(() => new Error(
error.error?.message || error.message || 'An unexpected error occurred while creating merchant'
));
})
catchError(error => this.handleError('createMerchant', error))
);
}
@ -52,328 +61,313 @@ export class MerchantConfigService {
.set('limit', limit.toString());
if (params?.query) {
httpParams = httpParams.set('query', params.query);
httpParams = httpParams.set('query', params.query.trim());
}
// L'API retourne directement un tableau de merchants
return this.http.get<ApiMerchant[]>(this.baseApiUrl, { params: httpParams }).pipe(
console.log(`📥 Loading merchants page ${page}, limit ${limit}`, params);
return this.http.get<ApiMerchant[]>(this.baseApiUrl, {
params: httpParams
}).pipe(
timeout(this.REQUEST_TIMEOUT),
retry(this.MAX_RETRIES),
map(apiMerchants => {
const total = apiMerchants.length;
const total = apiMerchants.length;
const totalPages = Math.ceil(total / limit);
// Appliquer la pagination côté client
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedItems = apiMerchants.slice(startIndex, endIndex);
return {
items: paginatedItems.map(apiMerchant => this.mapApiMerchantToMerchant(apiMerchant)),
const response: PaginatedResponse<Merchant> = {
items: paginatedItems.map(apiMerchant =>
this.dataAdapter.convertApiMerchantToFrontend(apiMerchant)
),
total: total,
page: page,
limit: limit,
totalPages: totalPages
};
console.log(`✅ Loaded ${response.items.length} merchants`);
return response;
}),
catchError(error => {
console.error('Error loading merchants:', error);
return throwError(() => error);
})
catchError(error => this.handleError('getMerchants', error))
);
}
getAllMerchants(params?: SearchMerchantsParams): Observable<Merchant[]> {
let httpParams = new HttpParams();
if (params?.query) {
httpParams = httpParams.set('query', params.query.trim());
}
return this.http.get<ApiMerchant[]>(this.baseApiUrl, { params: httpParams }).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiMerchants =>
apiMerchants.map(merchant =>
this.dataAdapter.convertApiMerchantToFrontend(merchant)
)
),
catchError(error => this.handleError('getAllMerchants', error))
);
}
getMerchantById(id: string): Observable<Merchant> {
const numericId = parseInt(id);
// L'API retourne directement l'objet merchant
const numericId = this.convertIdToNumber(id);
console.log(`📥 Loading merchant ${id}`);
return this.http.get<ApiMerchant>(`${this.baseApiUrl}/${numericId}`).pipe(
timeout(this.REQUEST_TIMEOUT),
retry(this.MAX_RETRIES),
map(apiMerchant => {
return this.mapApiMerchantToMerchant(apiMerchant);
console.log(`✅ Merchant ${id} loaded successfully`);
return this.dataAdapter.convertApiMerchantToFrontend(apiMerchant);
}),
catchError(error => {
console.error(`Error loading merchant ${id}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('getMerchantById', error, { merchantId: id }))
);
}
updateMerchant(id: string, updateMerchantDto: UpdateMerchantDto): Observable<Merchant> {
const numericId = parseInt(id);
// L'API retourne directement l'objet mis à jour
return this.http.patch<ApiMerchant>(`${this.baseApiUrl}/${numericId}`, updateMerchantDto).pipe(
const numericId = this.convertIdToNumber(id);
const apiDto = this.dataAdapter.convertUpdateMerchantToApi(updateMerchantDto);
console.log(`📤 Updating merchant ${id}:`, apiDto);
return this.http.patch<ApiMerchant>(`${this.baseApiUrl}/${numericId}`, apiDto).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiMerchant => {
return this.mapApiMerchantToMerchant(apiMerchant);
console.log(`✅ Merchant ${id} updated successfully`);
return this.dataAdapter.convertApiMerchantToFrontend(apiMerchant);
}),
catchError(error => {
console.error(`Error updating merchant ${id}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('updateMerchant', error, { merchantId: id }))
);
}
deleteMerchant(id: string): Observable<void> {
const numericId = parseInt(id);
// L'API ne retourne probablement rien ou un simple message
const numericId = this.convertIdToNumber(id);
console.log(`🗑️ Deleting merchant ${id}`);
return this.http.delete<void>(`${this.baseApiUrl}/${numericId}`).pipe(
timeout(this.REQUEST_TIMEOUT),
map(() => {
console.log(`✅ Merchant ${id} deleted successfully`);
}),
catchError(error => {
console.error(`Error deleting merchant ${id}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('deleteMerchant', error, { merchantId: id }))
);
}
// User Management
// ==================== USER MANAGEMENT ====================
addUserToMerchant(addUserDto: AddUserToMerchantDto): Observable<MerchantUser> {
// Convertir merchantPartnerId en number pour l'API
// CONVERSION AVEC L'ADAPTER
const apiDto = {
...addUserDto,
merchantPartnerId: addUserDto.merchantPartnerId ? parseInt(addUserDto.merchantPartnerId) : undefined
merchantPartnerId: addUserDto.merchantPartnerId ?
this.convertIdToNumber(addUserDto.merchantPartnerId) : undefined
};
// L'API retourne directement l'utilisateur ajouté
console.log('📤 Adding user to merchant:', apiDto);
return this.http.post<ApiMerchantUser>(`${this.baseApiUrl}/users`, apiDto).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiUser => {
return this.mapApiUserToUser(apiUser);
console.log('✅ User added to merchant successfully');
// ✅ UTILISATION DE L'ADAPTER
return this.dataAdapter.convertApiUserToFrontend(apiUser);
}),
catchError(error => {
console.error('Error adding user to merchant:', error);
return throwError(() => error);
})
catchError(error => this.handleError('addUserToMerchant', error))
);
}
getMerchantUsers(merchantId: string): Observable<MerchantUser[]> {
const numericMerchantId = parseInt(merchantId);
// Option 1: Si vous avez un endpoint spécifique pour les users
// return this.http.get<ApiMerchantUser[]>(`${this.baseApiUrl}/${numericMerchantId}/users`).pipe(
// Option 2: Récupérer le merchant complet et extraire les users
const numericMerchantId = this.convertIdToNumber(merchantId);
return this.http.get<ApiMerchant>(`${this.baseApiUrl}/${numericMerchantId}`).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiMerchant => {
// Retourner les users mappés depuis merchantUsers
return (apiMerchant.merchantUsers || []).map(user => this.mapApiUserToUser(user));
return (apiMerchant.users || []).map(user =>
// ✅ UTILISATION DE L'ADAPTER
this.dataAdapter.convertApiUserToFrontend(user)
);
}),
catchError(error => {
console.error(`Error loading users for merchant ${merchantId}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('getMerchantUsers', error, { merchantId }))
);
}
updateUserRole(merchantId: string, userId: string, updateRoleDto: UpdateUserRoleDto): Observable<MerchantUser> {
const numericMerchantId = parseInt(merchantId);
// L'API retourne directement l'utilisateur mis à jour
const numericMerchantId = this.convertIdToNumber(merchantId);
return this.http.patch<ApiMerchantUser>(
`${this.baseApiUrl}/${numericMerchantId}/users/${userId}/role`,
updateRoleDto
).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiUser => {
return this.mapApiUserToUser(apiUser);
console.log(`✅ User ${userId} role updated successfully`);
// ✅ UTILISATION DE L'ADAPTER
return this.dataAdapter.convertApiUserToFrontend(apiUser);
}),
catchError(error => {
console.error(`Error updating user role for merchant ${merchantId}, user ${userId}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('updateUserRole', error, { merchantId, userId }))
);
}
removeUserFromMerchant(merchantId: string, userId: string): Observable<void> {
const numericMerchantId = parseInt(merchantId);
// L'API ne retourne probablement rien
const numericMerchantId = this.convertIdToNumber(merchantId);
return this.http.delete<void>(`${this.baseApiUrl}/${numericMerchantId}/users/${userId}`).pipe(
timeout(this.REQUEST_TIMEOUT),
map(() => {
console.log(`✅ User ${userId} removed from merchant ${merchantId} successfully`);
}),
catchError(error => {
console.error(`Error removing user ${userId} from merchant ${merchantId}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('removeUserFromMerchant', error, { merchantId, userId }))
);
}
getUserMerchants(userId: string): Observable<Merchant[]> {
// L'API retourne directement un tableau de merchants
return this.http.get<ApiMerchant[]>(`${this.baseApiUrl}/user/${userId}`).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiMerchants => {
return apiMerchants.map(merchant => this.mapApiMerchantToMerchant(merchant));
return apiMerchants.map(merchant =>
this.dataAdapter.convertApiMerchantToFrontend(merchant)
);
}),
catchError(error => {
console.error(`Error loading merchants for user ${userId}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('getUserMerchants', error, { userId }))
);
}
// Config Management
// ==================== CONFIG MANAGEMENT ====================
addConfigToMerchant(merchantId: string, config: Omit<MerchantConfig, 'id' | 'merchantPartnerId'>): Observable<MerchantConfig> {
const numericMerchantId = parseInt(merchantId);
const numericMerchantId = this.convertIdToNumber(merchantId);
// CONVERSION AVEC L'ADAPTER
const apiConfig: Omit<ApiMerchantConfig, 'id'> = {
...config,
operatorId: config.operatorId,
merchantPartnerId: numericMerchantId
};
// L'API retourne directement la configuration créée
console.log(`📤 Adding config to merchant ${merchantId}:`, apiConfig);
return this.http.post<ApiMerchantConfig>(`${this.baseApiUrl}/${numericMerchantId}/configs`, apiConfig).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiConfig => {
return this.mapApiConfigToConfig(apiConfig);
console.log('✅ Config added to merchant successfully');
// ✅ UTILISATION DE L'ADAPTER
return this.dataAdapter.convertApiConfigToFrontend(apiConfig);
}),
catchError(error => {
console.error(`Error adding config to merchant ${merchantId}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('addConfigToMerchant', error, { merchantId }))
);
}
updateConfig(configId: string, config: Partial<MerchantConfig>): Observable<MerchantConfig> {
const numericConfigId = parseInt(configId);
const numericConfigId = this.convertIdToNumber(configId);
// Préparer l'objet de configuration pour l'API
const apiConfig: Partial<ApiMerchantConfig> = {
name: config.name,
value: config.value,
operatorId: config.operatorId,
// Si merchantPartnerId est présent, le convertir en number
...(config.merchantPartnerId && {
merchantPartnerId: parseInt(config.merchantPartnerId)
})
};
const apiConfig = this.dataAdapter.convertConfigUpdateToApi(config);
console.log(`📤 Updating config ${configId}:`, apiConfig);
// L'API retourne directement la configuration mise à jour
return this.http.patch<ApiMerchantConfig>(`${this.baseApiUrl}/configs/${numericConfigId}`, apiConfig).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiConfig => {
return this.mapApiConfigToConfig(apiConfig);
console.log(`✅ Config ${configId} updated successfully`);
// ✅ UTILISATION DE L'ADAPTER
return this.dataAdapter.convertApiConfigToFrontend(apiConfig);
}),
catchError(error => {
console.error(`Error updating config ${configId}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('updateConfig', error, { configId }))
);
}
deleteConfig(configId: string): Observable<void> {
const numericConfigId = parseInt(configId);
// L'API ne retourne probablement rien
const numericConfigId = this.convertIdToNumber(configId);
console.log(`🗑️ Deleting config ${configId}`);
return this.http.delete<void>(`${this.baseApiUrl}/configs/${numericConfigId}`).pipe(
timeout(this.REQUEST_TIMEOUT),
map(() => {
console.log(`✅ Config ${configId} deleted successfully`);
}),
catchError(error => {
console.error(`Error deleting config ${configId}:`, error);
return throwError(() => error);
})
catchError(error => this.handleError('deleteConfig', error, { configId }))
);
}
// Technical Contacts Management
addTechnicalContactToMerchant(merchantId: string, contact: Omit<TechnicalContact, 'id' | 'merchantPartnerId'>): Observable<TechnicalContact> {
const numericMerchantId = parseInt(merchantId);
const apiContact: Omit<ApiTechnicalContact, 'id'> = {
...contact,
merchantPartnerId: numericMerchantId
};
// L'API retourne directement le contact créé
return this.http.post<ApiTechnicalContact>(`${this.baseApiUrl}/${numericMerchantId}/technical-contacts`, apiContact).pipe(
map(apiContact => {
return this.mapApiContactToContact(apiContact);
}),
catchError(error => {
console.error(`Error adding technical contact to merchant ${merchantId}:`, error);
return throwError(() => error);
})
getMerchantConfigs(merchantId: string): Observable<MerchantConfig[]> {
return this.getMerchantById(merchantId).pipe(
map(merchant => merchant?.configs ?? [])
);
}
updateTechnicalContact(contactId: string, contact: Partial<TechnicalContact>): Observable<TechnicalContact> {
const numericContactId = parseInt(contactId);
// ==================== UTILITY METHODS ====================
private handleError(operation: string, error: any, context?: any): Observable<never> {
console.error(`❌ Error in ${operation}:`, error, context);
let userMessage: string;
const apiContact: Partial<ApiTechnicalContact> = {
firstName: contact.firstName,
lastName: contact.lastName,
phone: contact.phone,
email: contact.email,
// Si merchantPartnerId est présent, le convertir en number
...(contact.merchantPartnerId && {
merchantPartnerId: parseInt(contact.merchantPartnerId)
})
if (error instanceof HttpErrorResponse) {
switch (error.status) {
case 0:
userMessage = 'Erreur de connexion. Vérifiez votre connexion internet.';
break;
case 400:
userMessage = 'Données invalides. Vérifiez les informations saisies.';
break;
case 401:
userMessage = 'Authentification requise. Veuillez vous reconnecter.';
break;
case 403:
userMessage = 'Vous n\'avez pas les permissions pour effectuer cette action.';
break;
case 404:
userMessage = 'Ressource non trouvée.';
break;
case 409:
userMessage = 'Conflit de données. Cette ressource existe peut-être déjà.';
break;
case 500:
userMessage = 'Erreur serveur. Veuillez réessayer plus tard.';
break;
case 503:
userMessage = 'Service temporairement indisponible. Veuillez réessayer plus tard.';
break;
default:
userMessage = 'Une erreur inattendue est survenue.';
}
} else if (error.name === 'TimeoutError') {
userMessage = 'La requête a expiré. Veuillez réessayer.';
} else {
userMessage = 'Une erreur inattendue est survenue.';
}
const errorDetails = {
operation,
context,
status: error.status,
message: error.message,
url: error.url
};
console.error('Error details:', errorDetails);
// L'API retourne directement le contact mis à jour
return this.http.patch<ApiTechnicalContact>(`${this.baseApiUrl}/technical-contacts/${numericContactId}`, apiContact).pipe(
map(apiContact => {
return this.mapApiContactToContact(apiContact);
}),
catchError(error => {
console.error(`Error updating technical contact ${contactId}:`, error);
return throwError(() => error);
})
);
return throwError(() => new Error(userMessage));
}
deleteTechnicalContact(contactId: string): Observable<void> {
const numericContactId = parseInt(contactId);
// L'API ne retourne probablement rien
return this.http.delete<void>(`${this.baseApiUrl}/technical-contacts/${numericContactId}`).pipe(
map(() => {
}),
catchError(error => {
console.error(`Error deleting technical contact ${contactId}:`, error);
return throwError(() => error);
})
);
}
// Stats
getMerchantStats(): Observable<MerchantStatsResponse> {
// L'API retourne directement les stats
return this.http.get<MerchantStatsResponse>(`${this.baseApiUrl}/stats`).pipe(
map(stats => {
return stats;
}),
catchError(error => {
console.error('Error loading merchant stats:', error);
return throwError(() => error);
})
);
}
// ==================== MAPPING METHODS ====================
// Méthode principale de mapping Merchant
private mapApiMerchantToMerchant(apiMerchant: any): Merchant {
return {
...apiMerchant,
id: apiMerchant.id?.toString() || '', // number → string
configs: (apiMerchant.configs || []).map((config: any) => this.mapApiConfigToConfig(config)),
users: (apiMerchant.merchantUsers || []).map((user: any) => this.mapApiUserToUser(user)), // merchantUsers → users
technicalContacts: (apiMerchant.technicalContacts || []).map((contact: any) => this.mapApiContactToContact(contact)),
};
}
// Méthode de mapping pour les configurations
private mapApiConfigToConfig(apiConfig: any): MerchantConfig {
return {
...apiConfig,
id: apiConfig.id?.toString(), // number → string
merchantPartnerId: apiConfig.merchantPartnerId?.toString() // number → string
};
}
// Méthode de mapping pour les contacts techniques
private mapApiContactToContact(apiContact: any): TechnicalContact {
return {
...apiContact,
id: apiContact.id?.toString(), // number → string
merchantPartnerId: apiContact.merchantPartnerId?.toString() // number → string
};
}
// Méthode de mapping pour les utilisateurs
private mapApiUserToUser(apiUser: any): MerchantUser {
return {
...apiUser,
merchantPartnerId: apiUser.merchantPartnerId?.toString() // number → string
};
private convertIdToNumber(id: string): number {
if (!id) {
throw new Error('ID cannot be null or undefined');
}
const numericId = Number(id);
if (isNaN(numericId)) {
throw new Error(`Invalid ID format: ${id}`);
}
return numericId;
}
}

View File

@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { catchError, map, of, Subject, takeUntil } from 'rxjs';
import { catchError, finalize, map, of, Subject, takeUntil } from 'rxjs';
import { MerchantConfigService } from './merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
@ -19,10 +19,14 @@ import {
Operator,
UpdateMerchantDto,
Merchant,
MerchantConfig,
TechnicalContact
} from '@core/models/merchant-config.model';
import { UserRole, UserType, PaginatedUserResponse, User } from '@core/models/dcb-bo-hub-user.model';
import { HubUsersService } from '../hub-users-management/hub-users.service';
import { MerchantDataAdapter } from './merchant-data-adapter.service';
@Component({
selector: 'app-merchant-config',
standalone: true,
@ -41,10 +45,9 @@ import { HubUsersService } from '../hub-users-management/hub-users.service';
})
export class MerchantConfigManagement implements OnInit, OnDestroy {
private modalService = inject(NgbModal);
private fb = inject(FormBuilder);
private authService = inject(AuthService);
private merchantConfigService = inject(MerchantConfigService);
private hubUsersService = inject(HubUsersService);
private dataAdapter = inject(MerchantDataAdapter);
protected roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
@ -87,6 +90,10 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
selectedMerchantForEdit: Merchant | null = null;
selectedMerchantForDelete: Merchant | null = null;
// États pour la gestion des modifications
editingConfigs: { [configId: string]: boolean } = {};
editingContacts: { [contactId: string]: boolean } = {};
// Références aux templates de modals
@ViewChild('createMerchantModal') createMerchantModal!: TemplateRef<any>;
@ViewChild('editMerchantModal') editMerchantModal!: TemplateRef<any>;
@ -94,6 +101,7 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
// Références aux composants enfants
@ViewChild(MerchantConfigsList) merchantConfigsList!: MerchantConfigsList;
@ViewChild(MerchantConfigView) merchantConfigView!: MerchantConfigView;
// Opérateurs disponibles
operators = [
@ -124,7 +132,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
ngOnInit() {
this.activeTab = 'list';
this.loadCurrentUserPermissions();
this.loadMerchantPartnersIfNeeded();
this.initializeMerchantPartnerContext();
}
@ -163,6 +170,27 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
}
// Gestion des contacts dans l'édition
addTechnicalContactInEdit(): void {
if (!this.selectedMerchantForEdit?.technicalContacts) {
this.selectedMerchantForEdit!.technicalContacts = [];
}
this.selectedMerchantForEdit?.technicalContacts.push({
firstName: '',
lastName: '',
phone: '',
email: ''
});
}
removeTechnicalContactInEdit(index: number): void {
if (this.selectedMerchantForEdit?.technicalContacts &&
this.selectedMerchantForEdit.technicalContacts.length > 1) {
this.selectedMerchantForEdit.technicalContacts.splice(index, 1);
}
}
/**
* Méthodes pour la gestion des configurations
*/
@ -184,60 +212,44 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
}
// ==================== CONVERSION IDS ====================
/**
* Convertit un ID number en string pour Angular
*/
private convertIdToString(id: number): string {
return id.toString();
//Gestion des configs dans l'édition
addConfigInEdit(): void {
if (!this.selectedMerchantForEdit?.configs) {
this.selectedMerchantForEdit!.configs = [];
}
this.selectedMerchantForEdit?.configs.push({
name: ConfigType.API_KEY,
value: '',
operatorId: Operator.ORANGE_OSN
});
}
removeConfigInEdit(index: number): void {
if (this.selectedMerchantForEdit?.configs && this.selectedMerchantForEdit.configs.length > 1) {
this.selectedMerchantForEdit.configs.splice(index, 1);
}
}
// ==================== CONVERSION IDS ====================
/**
* Convertit un marchand avec des IDs number en string
*/
private convertMerchantToFrontend(merchant: any): Merchant {
return {
...merchant,
id: merchant.id ? this.convertIdToString(merchant.id) : undefined,
configs: merchant.configs?.map((config: any) => ({
...config,
id: config.id ? this.convertIdToString(config.id) : undefined,
merchantPartnerId: config.merchantPartnerId ? this.convertIdToString(config.merchantPartnerId) : undefined
})) || [],
technicalContacts: merchant.technicalContacts?.map((contact: any) => ({
...contact,
id: contact.id ? this.convertIdToString(contact.id) : undefined,
merchantPartnerId: contact.merchantPartnerId ? this.convertIdToString(contact.merchantPartnerId) : undefined
})) || [],
users: merchant.users?.map((user: any) => ({
...user,
merchantPartnerId: user.merchantPartnerId ? this.convertIdToString(user.merchantPartnerId) : undefined
})) || []
};
return this.dataAdapter.convertApiMerchantToFrontend(merchant);
}
/**
* Convertit un DTO avec des IDs string en number pour l'API
*/
private convertMerchantToBackend(dto: any): any {
return this.dataAdapter.convertCreateMerchantToApi(dto);
}
console.log("convertMerchantToBackend")
return {
...dto,
// Pas de conversion pour l'ID du marchand en création (généré par le backend)
configs: dto.configs?.map((config: any) => ({
...config,
// Pas de conversion pour l'ID de config en création
operatorId: parseInt(config.operatorId),
merchantPartnerId: config.merchantPartnerId
})) || [],
technicalContacts: dto.technicalContacts?.map((contact: any) => ({
...contact,
// Pas de conversion pour l'ID de contact en création
merchantPartnerId: contact.merchantPartnerId
})) || []
};
// Conversion pour la mise à jour
private convertUpdateMerchantToBackend(dto: UpdateMerchantDto, existingMerchant?: Merchant): any {
return this.dataAdapter.convertUpdateMerchantToApi(dto, existingMerchant);
}
// ==================== GESTION DES PERMISSIONS ====================
@ -300,40 +312,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
// ==================== GESTION DES MARCHANDS ====================
private loadMerchantPartnersIfNeeded(): void {
if (this.canManageMerchants) {
this.loadMerchantPartners();
}
}
private loadMerchantPartners(): void {
this.loadingMerchantPartners = true;
this.merchantPartnersError = '';
this.hubUsersService.getAllDcbPartners()
.pipe(
map((response: PaginatedUserResponse) => response.users),
takeUntil(this.destroy$),
catchError(error => {
console.error('Error loading merchant partners:', error);
this.loadingMerchantPartners = false;
this.merchantPartnersError = 'Impossible de charger la liste des marchands';
return of([] as User[]);
})
)
.subscribe({
next: (partners) => {
this.merchantPartners = partners;
this.loadingMerchantPartners = false;
},
error: (error) => {
console.error('❌ Erreur lors du chargement des marchands:', error);
this.loadingMerchantPartners = false;
this.merchantPartnersError = 'Impossible de charger la liste des marchands';
}
});
}
private initializeMerchantPartnerContext(): void {
if ((this.currentUserRole === UserRole.DCB_PARTNER ||
this.currentUserRole === UserRole.DCB_PARTNER_ADMIN) &&
@ -413,6 +391,7 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
onEditConfigRequested(configId: string): void {
this.openEditMerchantModal(configId);
}
// ==================== GESTION DES MODALS ====================
openModal(content: TemplateRef<any>, size: 'sm' | 'lg' | 'xl' = 'lg'): void {
@ -535,30 +514,28 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
});
}
// Mise à jour COMPLÈTE du merchant
updateMerchant(): void {
if (!this.selectedMerchantForEdit) {
this.updateMerchantError = 'Aucun marchand sélectionné pour modification';
return;
}
const validation = MerchantUtils.validateMerchantCreation(this.selectedMerchantForEdit as CreateMerchantDto);
if (validation.length > 0) {
this.updateMerchantError = validation.join(', ');
// Validation des données complètes
const validation = this.validateMerchantUpdate(this.selectedMerchantForEdit);
if (!validation.isValid) {
this.updateMerchantError = validation.errors.join(', ');
return;
}
this.updatingMerchant = true;
this.updateMerchantError = '';
// Conversion pour l'API
// Conversion pour l'API avec TOUTES les données
const merchantId = this.selectedMerchantForEdit.id!;
const updateDto: UpdateMerchantDto = {
name: this.selectedMerchantForEdit.name,
logo: this.selectedMerchantForEdit.logo,
description: this.selectedMerchantForEdit.description,
adresse: this.selectedMerchantForEdit.adresse,
phone: this.selectedMerchantForEdit.phone
};
const updateDto = this.convertUpdateMerchantToBackend(this.selectedMerchantForEdit, this.selectedMerchantForEdit);
console.log('📤 Updating merchant with full data:', updateDto);
this.merchantConfigService.updateMerchant(merchantId, updateDto)
.pipe(takeUntil(this.destroy$))
@ -569,6 +546,7 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.updatingMerchant = false;
this.modalService.dismissAll();
this.refreshMerchantsConfigsView();
this.refreshMerchantsList();
// Mettre à jour le cache
@ -576,6 +554,8 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.merchantProfiles[this.selectedMerchantId] = frontendMerchant;
}
this.successMessage = 'Marchand modifié avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
@ -587,6 +567,74 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
});
}
// Validation complète pour la mise à jour
validateMerchantUpdate(merchant: UpdateMerchantDto): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
// Validation des champs de base
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');
}
// Validation des configurations
if (!merchant.configs || merchant.configs.length === 0) {
errors.push('Au moins une configuration est requise');
} else {
merchant.configs.forEach((config, index) => {
if (!config.name?.trim()) {
errors.push(`Le type de configuration ${index + 1} est requis`);
}
if (!config.value?.trim()) {
errors.push(`La valeur de configuration ${index + 1} est requise`);
}
if (!config.operatorId) {
errors.push(`L'opérateur de configuration ${index + 1} est requis`);
}
});
}
// Validation des contacts techniques
if (!merchant.technicalContacts || merchant.technicalContacts.length === 0) {
errors.push('Au moins un contact technique est requis');
} else {
merchant.technicalContacts.forEach((contact, index) => {
if (!contact.firstName?.trim()) {
errors.push(`Le prénom du contact ${index + 1} est requis`);
}
if (!contact.lastName?.trim()) {
errors.push(`Le nom du contact ${index + 1} est requis`);
}
if (!contact.phone?.trim()) {
errors.push(`Le téléphone du contact ${index + 1} est requis`);
}
if (!contact.email?.trim()) {
errors.push(`L'email du contact ${index + 1} est requis`);
} else if (!this.isValidEmail(contact.email)) {
errors.push(`L'email du contact ${index + 1} est invalide`);
}
});
}
return {
isValid: errors.length === 0,
errors: errors
};
}
// Validation d'email
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
confirmDeleteMerchant(): void {
if (!this.selectedMerchantForDelete) {
this.deleteMerchantError = 'Aucun marchand sélectionné pour suppression';
@ -653,7 +701,11 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
private populateEditForm(merchant: Merchant): void {
this.selectedMerchantForEdit = { ...merchant };
this.selectedMerchantForEdit = {
...merchant,
configs: merchant.configs.map(config => ({ ...config })),
technicalContacts: merchant.technicalContacts.map(contact => ({ ...contact }))
};
}
private refreshMerchantsList(): void {
@ -666,6 +718,16 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
}
private refreshMerchantsConfigsView(): void {
if (this.merchantConfigView && typeof this.merchantConfigView.refresh === 'function') {
console.log('🔄 Refreshing merchants config view...');
this.merchantConfigView.refresh();
} else {
console.warn('❌ MerchantConfigsView component not available for refresh');
this.showTab('list');
}
}
// ==================== GESTION DES ERREURS ====================
private getCreateErrorMessage(error: any): string {
@ -702,6 +764,38 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
return MerchantUtils.getConfigTypeName(configType);
}
// Méthodes utilitaires pour le template d'édition
trackByConfigId(index: number, config: MerchantConfig): string {
return config.id || `new-${index}`;
}
trackByContactId(index: number, contact: TechnicalContact): string {
return contact.id || `new-${index}`;
}
// Méthode pour détecter les configs sensibles
isSensitiveConfig(config: MerchantConfig): boolean {
return config.name === ConfigType.API_KEY ||
config.name === ConfigType.SECRET_KEY;
}
// Méthode pour obtenir l'icône d'un type de config
getConfigTypeIconSafe(configName: string): string {
const icons: { [key: string]: string } = {
[ConfigType.API_KEY]: 'lucideKey',
[ConfigType.SECRET_KEY]: 'lucideShield',
[ConfigType.WEBHOOK_URL]: 'lucideGlobe',
[ConfigType.CALLBACK_URL]: 'lucideRefreshCw',
[ConfigType.TIMEOUT]: 'lucideClock',
[ConfigType.RETRY_COUNT]: 'lucideRepeat',
[ConfigType.CUSTOM]: 'lucideSettings'
};
return icons[configName] || 'lucideSettings';
}
// Propriété pour le message de succès
successMessage: string = '';
// ==================== GETTERS TEMPLATE ====================
get currentMerchantProfile(): Merchant | null {

View File

@ -0,0 +1,447 @@
import { Injectable } from '@angular/core';
import {
Merchant,
CreateMerchantDto,
UpdateMerchantDto,
MerchantConfig,
TechnicalContact,
ApiMerchant,
ApiMerchantConfig,
ApiTechnicalContact,
ConfigType,
Operator
} from '@core/models/merchant-config.model';
@Injectable({
providedIn: 'root'
})
export class MerchantDataAdapter {
// ==================== CONVERSION API → FRONTEND ====================
/**
* Convertit un merchant de l'API vers le format Frontend
*/
convertApiMerchantToFrontend(apiMerchant: ApiMerchant): Merchant {
if (!apiMerchant) {
throw new Error('Merchant data is required for conversion');
}
return {
...apiMerchant,
id: this.convertIdToString(apiMerchant.id),
configs: (apiMerchant.configs || []).map(config =>
this.convertApiConfigToFrontend(config)
),
technicalContacts: (apiMerchant.technicalContacts || []).map(contact =>
this.convertApiContactToFrontend(contact)
),
users: (apiMerchant.users || []).map(user =>
this.convertApiUserToFrontend(user)
)
};
}
/**
* Convertit une configuration de l'API vers le format Frontend
*/
convertApiConfigToFrontend(apiConfig: ApiMerchantConfig): MerchantConfig {
return {
...apiConfig,
id: this.convertIdToString(apiConfig.id),
merchantPartnerId: this.convertIdToString(apiConfig.merchantPartnerId)
};
}
/**
* Convertit un contact technique de l'API vers le format Frontend
*/
convertApiContactToFrontend(apiContact: ApiTechnicalContact): TechnicalContact {
return {
...apiContact,
id: this.convertIdToString(apiContact.id),
merchantPartnerId: this.convertIdToString(apiContact.merchantPartnerId)
};
}
/**
* Convertit un utilisateur merchant de l'API vers le format Frontend
*/
convertApiUserToFrontend(apiUser: any): any {
return {
...apiUser,
merchantPartnerId: this.convertIdToString(apiUser.merchantPartnerId)
};
}
// ==================== CONVERSION FRONTEND → API ====================
/**
* Convertit un DTO de création pour l'API
*/
convertCreateMerchantToApi(dto: CreateMerchantDto): any {
this.validateCreateMerchantDto(dto);
return {
name: dto.name?.trim(),
logo: dto.logo?.trim() || undefined,
description: dto.description?.trim() || undefined,
adresse: dto.adresse?.trim(),
phone: dto.phone?.trim(),
configs: (dto.configs || []).map(config => ({
name: config.name,
value: config.value?.trim(),
operatorId: this.validateOperatorId(config.operatorId)
})),
technicalContacts: (dto.technicalContacts || []).map(contact => ({
firstName: contact.firstName?.trim(),
lastName: contact.lastName?.trim(),
phone: contact.phone?.trim(),
email: contact.email?.trim()
}))
};
}
/**
* Convertit un DTO de mise à jour pour l'API
*/
convertUpdateMerchantToApi(dto: UpdateMerchantDto, existingMerchant?: Merchant): any {
this.validateUpdateMerchantDto(dto);
const updateData: any = {};
// Champs de base
if (dto.name !== undefined) updateData.name = dto.name?.trim();
if (dto.logo !== undefined) updateData.logo = dto.logo?.trim() || null;
if (dto.description !== undefined) updateData.description = dto.description?.trim() || null;
if (dto.adresse !== undefined) updateData.adresse = dto.adresse?.trim();
if (dto.phone !== undefined) updateData.phone = dto.phone?.trim();
// Configurations - seulement si présentes dans le DTO
if (dto.configs !== undefined) {
updateData.configs = (dto.configs || []).map(config => {
const apiConfig: any = {
name: config.name,
value: config.value?.trim(),
operatorId: this.validateOperatorId(config.operatorId)
};
return apiConfig;
});
}
// Contacts techniques - seulement si présents dans le DTO
if (dto.technicalContacts !== undefined) {
updateData.technicalContacts = (dto.technicalContacts || []).map(contact => {
const apiContact: any = {
firstName: contact.firstName?.trim(),
lastName: contact.lastName?.trim(),
phone: contact.phone?.trim(),
email: contact.email?.trim()
};
return apiContact;
});
}
return updateData;
}
/**
* Convertit une configuration pour la mise à jour via endpoint spécifique
*/
convertConfigUpdateToApi(config: Partial<MerchantConfig>): any {
const updateData: any = {};
if (config.name !== undefined) updateData.name = config.name;
if (config.value !== undefined) updateData.value = config.value?.trim();
if (config.operatorId !== undefined) {
updateData.operatorId = this.validateOperatorId(config.operatorId);
}
if (config.merchantPartnerId !== undefined) {
updateData.merchantPartnerId = this.convertIdToNumber(config.merchantPartnerId);
}
return updateData;
}
// ==================== VALIDATION ====================
/**
* Valide le DTO de création
*/
private validateCreateMerchantDto(dto: CreateMerchantDto): void {
const errors: string[] = [];
if (!dto.name?.trim()) {
errors.push('Le nom du merchant est requis');
}
if (!dto.adresse?.trim()) {
errors.push('L\'adresse est requise');
}
if (!dto.phone?.trim()) {
errors.push('Le téléphone est requis');
}
if (!dto.technicalContacts || dto.technicalContacts.length === 0) {
errors.push('Au moins un contact technique est requis');
} else {
dto.technicalContacts.forEach((contact, index) => {
if (!contact.firstName?.trim()) {
errors.push(`Le prénom du contact ${index + 1} est requis`);
}
if (!contact.lastName?.trim()) {
errors.push(`Le nom du contact ${index + 1} est requis`);
}
if (!contact.phone?.trim()) {
errors.push(`Le téléphone du contact ${index + 1} est requis`);
}
if (!contact.email?.trim()) {
errors.push(`L'email du contact ${index + 1} est requis`);
} else if (!this.isValidEmail(contact.email)) {
errors.push(`L'email du contact ${index + 1} est invalide`);
}
});
}
if (!dto.configs || dto.configs.length === 0) {
errors.push('Au moins une configuration est requise');
} else {
dto.configs.forEach((config, index) => {
if (!config.name?.trim()) {
errors.push(`Le type de configuration ${index + 1} est requis`);
}
if (!config.value?.trim()) {
errors.push(`La valeur de configuration ${index + 1} est requise`);
}
if (!config.operatorId) {
errors.push(`L'opérateur de configuration ${index + 1} est requis`);
}
});
}
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.join(', ')}`);
}
}
/**
* Valide le DTO de mise à jour
*/
private validateUpdateMerchantDto(dto: UpdateMerchantDto): void {
const errors: string[] = [];
// Validation des champs de base si présents
if (dto.name !== undefined && !dto.name.trim()) {
errors.push('Le nom du merchant est requis');
}
if (dto.adresse !== undefined && !dto.adresse.trim()) {
errors.push('L\'adresse est requise');
}
if (dto.phone !== undefined && !dto.phone.trim()) {
errors.push('Le téléphone est requis');
}
// Validation des configurations si présentes
if (dto.configs !== undefined) {
if (dto.configs.length === 0) {
errors.push('Au moins une configuration est requise');
} else {
dto.configs.forEach((config, index) => {
if (!config.name?.trim()) {
errors.push(`Le type de configuration ${index + 1} est requis`);
}
if (!config.value?.trim()) {
errors.push(`La valeur de configuration ${index + 1} est requise`);
}
if (!config.operatorId) {
errors.push(`L'opérateur de configuration ${index + 1} est requis`);
}
});
}
}
// Validation des contacts si présents
if (dto.technicalContacts !== undefined) {
if (dto.technicalContacts.length === 0) {
errors.push('Au moins un contact technique est requis');
} else {
dto.technicalContacts.forEach((contact, index) => {
if (!contact.firstName?.trim()) {
errors.push(`Le prénom du contact ${index + 1} est requis`);
}
if (!contact.lastName?.trim()) {
errors.push(`Le nom du contact ${index + 1} est requis`);
}
if (!contact.phone?.trim()) {
errors.push(`Le téléphone du contact ${index + 1} est requis`);
}
if (!contact.email?.trim()) {
errors.push(`L'email du contact ${index + 1} est requis`);
} else if (!this.isValidEmail(contact.email)) {
errors.push(`L'email du contact ${index + 1} est invalide`);
}
});
}
}
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.join(', ')}`);
}
}
/**
* Valide un operatorId
*/
private validateOperatorId(operatorId: Operator | number | string | null): number {
if (operatorId === null || operatorId === undefined) {
throw new Error('Operator ID is required');
}
let numericId: number;
if (typeof operatorId === 'number') {
numericId = operatorId;
} else if (typeof operatorId === 'string') {
numericId = parseInt(operatorId, 10);
} else {
// Cas "sécurité" : si TS voit encore "never"
numericId = parseInt(String(operatorId), 10);
}
if (isNaN(numericId)) {
throw new Error(`Invalid operator ID: ${operatorId}`);
}
// Vérifier que l'opérateur fait partie des valeurs enum valides
const validOperators = Object.values(Operator).filter(val => typeof val === 'number');
if (!validOperators.includes(numericId)) {
throw new Error(`Unsupported operator ID: ${operatorId}`);
}
return numericId;
}
// ==================== UTILITAIRES DE CONVERSION ====================
/**
* Convertit un ID number en string
*/
private convertIdToString(id: number | undefined): string | undefined {
if (id === undefined || id === null) return undefined;
return id.toString();
}
/**
* Convertit un ID string en number
*/
private convertIdToNumber(id: string | undefined): number | undefined {
if (!id) return undefined;
const numericId = parseInt(id, 10);
if (isNaN(numericId)) {
throw new Error(`Invalid ID for conversion to number: ${id}`);
}
return numericId;
}
/**
* Valide un email
*/
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// ==================== MÉTHODES UTILITAIRES ====================
/**
* Crée un nouveau merchant vide pour l'édition
*/
createEmptyMerchant(): Merchant {
return {
name: '',
adresse: '',
phone: '',
configs: [this.createEmptyConfig()],
technicalContacts: [this.createEmptyContact()],
users: []
};
}
/**
* Crée une configuration vide
*/
createEmptyConfig(): MerchantConfig {
return {
name: ConfigType.API_KEY,
value: '',
operatorId: Operator.ORANGE_OSN
};
}
/**
* Crée un contact technique vide
*/
createEmptyContact(): TechnicalContact {
return {
firstName: '',
lastName: '',
phone: '',
email: ''
};
}
/**
* Clone un merchant profondément (pour éviter les mutations)
*/
cloneMerchant(merchant: Merchant): Merchant {
return {
...merchant,
configs: merchant.configs.map(config => ({ ...config })),
technicalContacts: merchant.technicalContacts.map(contact => ({ ...contact })),
users: merchant.users.map(user => ({ ...user }))
};
}
/**
* Vérifie si un merchant a é modifié
*/
hasMerchantChanged(original: Merchant, updated: Merchant): boolean {
return JSON.stringify(original) !== JSON.stringify(updated);
}
/**
* Extrait seulement les champs modifiés pour l'update
*/
getChangedFields(original: Merchant, updated: Merchant): Partial<UpdateMerchantDto> {
const changes: Partial<UpdateMerchantDto> = {};
if (original.name !== updated.name) changes.name = updated.name;
if (original.logo !== updated.logo) changes.logo = updated.logo;
if (original.description !== updated.description) changes.description = updated.description;
if (original.adresse !== updated.adresse) changes.adresse = updated.adresse;
if (original.phone !== updated.phone) changes.phone = updated.phone;
// Vérifier les changements dans les configurations
const configsChanged = JSON.stringify(original.configs) !== JSON.stringify(updated.configs);
if (configsChanged) {
changes.configs = updated.configs;
}
// Vérifier les changements dans les contacts
const contactsChanged = JSON.stringify(original.technicalContacts) !== JSON.stringify(updated.technicalContacts);
if (contactsChanged) {
changes.technicalContacts = updated.technicalContacts;
}
return changes;
}
}