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-04 21:06:44 +00:00
parent ad751d96bd
commit 389488bf28
5 changed files with 3 additions and 749 deletions

View File

@ -50,7 +50,7 @@ export class StartupService implements OnModuleInit {
async onModuleInit() { async onModuleInit() {
if (process.env.RUN_STARTUP_TESTS === 'true') { if (process.env.RUN_STARTUP_TESTS === 'true') {
this.logger.log('🚀 Starting comprehensive tests (Hub + Merchants with isolation)...'); this.logger.log('Starting comprehensive tests (Hub + Merchants with isolation)...');
await this.runAllTests(); await this.runAllTests();
} }
} }

View File

@ -1,298 +0,0 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
Request,
HttpStatus,
HttpCode,
Logger,
DefaultValuePipe,
ParseIntPipe,
BadRequestException,
} from '@nestjs/common';
import { AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
import {
MerchantPartnersService,
MerchantPartner,
CreateMerchantPartnerData,
MerchantStats,
} from '../services/merchant-partners.service';
import { RESOURCES } from '../../constants/resources';
import { SCOPES } from '../../constants/scopes';
// DTOs
import {
CreateMerchantPartnerDto,
UpdateMerchantPartnerDto,
SuspendMerchantPartnerDto,
} from '../dto/merchant-partners.dto';
export interface ApiResponse<T = any> {
success: boolean;
message: string;
data?: T;
timestamp: string;
}
export interface PaginatedResponse<T = any> {
items: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@Controller('partners')
@Resource(RESOURCES.MERCHANT_USER)
export class MerchantPartnersController {
private readonly logger = new Logger(MerchantPartnersController.name);
constructor(private readonly merchantPartnersService: MerchantPartnersService) {}
private createApiResponse<T>(
success: boolean,
message: string,
data?: T,
): ApiResponse<T> {
return {
success,
message,
data,
timestamp: new Date().toISOString(),
};
}
// ===== CRÉATION DE MERCHANT PARTNERS =====
@Post()
@Scopes(SCOPES.WRITE)
async createMerchantPartner(
@Body() createMerchantDto: CreateMerchantPartnerDto,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner>> {
this.logger.log(`Creating merchant partner: ${createMerchantDto.name}`);
const creatorId = req.user.sub;
const merchantData: CreateMerchantPartnerData = {
...createMerchantDto,
dcbPartnerOwner: {
username: createMerchantDto.dcbPartnerOwnerUsername,
email: createMerchantDto.dcbPartnerOwnerEmail,
firstName: createMerchantDto.dcbPartnerOwnerFirstName,
lastName: createMerchantDto.dcbPartnerOwnerLastName,
password: createMerchantDto.dcbPartnerOwnerPassword,
},
};
const merchant = await this.merchantPartnersService.createMerchantPartner(creatorId, merchantData);
this.logger.log(`Merchant partner created successfully: ${merchant.name} (ID: ${merchant.id})`);
return this.createApiResponse(
true,
'Merchant partner created successfully',
merchant,
);
}
// ===== RÉCUPÉRATION DE MERCHANT PARTNERS =====
@Get()
@Scopes(SCOPES.READ)
async getAllMerchantPartners(
@Request() req: any,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
@Query('status') status?: string,
): Promise<ApiResponse<PaginatedResponse<MerchantPartner>>> {
const requesterId = req.user.sub;
const safeLimit = Math.min(limit, 100);
const safePage = Math.max(page, 1);
let merchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId);
// Filtrage par statut
if (status && ['ACTIVE', 'SUSPENDED', 'PENDING'].includes(status)) {
merchants = merchants.filter(merchant => merchant.status === status);
}
// Pagination
const startIndex = (safePage - 1) * safeLimit;
const endIndex = startIndex + safeLimit;
const paginatedMerchants = merchants.slice(startIndex, endIndex);
const response: PaginatedResponse<MerchantPartner> = {
items: paginatedMerchants,
total: merchants.length,
page: safePage,
limit: safeLimit,
totalPages: Math.ceil(merchants.length / safeLimit),
};
return this.createApiResponse(
true,
'Merchant partners retrieved successfully',
response,
);
}
@Get('stats')
@Scopes(SCOPES.READ)
async getMerchantStats(
@Request() req: any,
): Promise<ApiResponse<MerchantStats>> {
const requesterId = req.user.sub;
const stats = await this.merchantPartnersService.getMerchantStats(requesterId);
return this.createApiResponse(
true,
'Merchant statistics retrieved successfully',
stats,
);
}
@Get(':id')
@Scopes(SCOPES.READ)
async getMerchantPartnerById(
@Param('id') merchantId: string,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner>> {
const requesterId = req.user.sub;
const merchant = await this.merchantPartnersService.getMerchantPartnerById(merchantId, requesterId);
return this.createApiResponse(
true,
'Merchant partner retrieved successfully',
merchant,
);
}
// ===== MISE À JOUR DE MERCHANT PARTNERS =====
@Put(':id')
@Scopes(SCOPES.WRITE)
async updateMerchantPartner(
@Param('id') merchantId: string,
@Body() updateMerchantDto: UpdateMerchantPartnerDto,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner>> {
this.logger.log(`Updating merchant partner: ${merchantId}`);
const requesterId = req.user.sub;
const merchant = await this.merchantPartnersService.updateMerchantPartner(
merchantId,
updateMerchantDto,
requesterId,
);
this.logger.log(`Merchant partner updated successfully: ${merchant.name}`);
return this.createApiResponse(
true,
'Merchant partner updated successfully',
merchant,
);
}
@Put(':id/suspend')
@Scopes(SCOPES.WRITE)
@HttpCode(HttpStatus.OK)
async suspendMerchantPartner(
@Param('id') merchantId: string,
@Body() suspendDto: SuspendMerchantPartnerDto,
@Request() req: any,
): Promise<ApiResponse<void>> {
this.logger.log(`Suspending merchant partner: ${merchantId}`);
const requesterId = req.user.sub;
await this.merchantPartnersService.suspendMerchantPartner(
merchantId,
suspendDto.reason,
requesterId,
);
this.logger.log(`Merchant partner suspended successfully: ${merchantId}`);
return this.createApiResponse(
true,
'Merchant partner suspended successfully',
);
}
@Put(':id/activate')
@Scopes(SCOPES.WRITE)
@HttpCode(HttpStatus.OK)
async activateMerchantPartner(
@Param('id') merchantId: string,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner>> {
this.logger.log(`Activating merchant partner: ${merchantId}`);
const requesterId = req.user.sub;
const merchant = await this.merchantPartnersService.updateMerchantPartner(
merchantId,
{ status: 'ACTIVE' },
requesterId,
);
this.logger.log(`Merchant partner activated successfully: ${merchant.name}`);
return this.createApiResponse(
true,
'Merchant partner activated successfully',
merchant,
);
}
// ===== SUPPRESSION DE MERCHANT PARTNERS =====
@Delete(':id')
@Scopes(SCOPES.DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMerchantPartner(
@Param('id') merchantId: string,
@Request() req: any,
): Promise<void> {
this.logger.log(`Deleting merchant partner: ${merchantId}`);
const requesterId = req.user.sub;
// Implémentez la logique de suppression si nécessaire
// await this.merchantPartnersService.deleteMerchantPartner(merchantId, requesterId);
this.logger.log(`Merchant partner deletion scheduled: ${merchantId}`);
}
// ===== RECHERCHE ET FILTRES =====
@Get('search/by-name')
@Scopes(SCOPES.READ)
async searchMerchantsByName(
@Query('name') name: string,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner[]>> {
if (!name || name.length < 2) {
throw new BadRequestException('Name must be at least 2 characters long');
}
this.logger.log(`Searching merchants by name: ${name}`);
const requesterId = req.user.sub;
const allMerchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId);
const filteredMerchants = allMerchants.filter(merchant =>
merchant.name.toLowerCase().includes(name.toLowerCase()),
);
this.logger.log(`Found ${filteredMerchants.length} merchants matching name: ${name}`);
return this.createApiResponse(
true,
'Merchants search completed successfully',
filteredMerchants,
);
}
}

View File

@ -1,96 +0,0 @@
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
Matches,
} from 'class-validator';
export class CreateMerchantPartnerDto {
@IsNotEmpty()
@IsString()
@MinLength(2)
name: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
legalName: string;
@IsNotEmpty()
@IsEmail()
email: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
address?: string;
// Propriétaire DCB_PARTNER
@IsNotEmpty()
@IsString()
@MinLength(3)
dcbPartnerOwnerUsername: string;
@IsNotEmpty()
@IsEmail()
dcbPartnerOwnerEmail: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
dcbPartnerOwnerFirstName: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
dcbPartnerOwnerLastName: string;
@IsNotEmpty()
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
})
dcbPartnerOwnerPassword: string;
}
export class UpdateMerchantPartnerDto {
@IsOptional()
@IsString()
@MinLength(2)
name?: string;
@IsOptional()
@IsString()
@MinLength(2)
legalName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsEnum(['ACTIVE', 'SUSPENDED', 'PENDING'])
status?: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
}
export class SuspendMerchantPartnerDto {
@IsNotEmpty()
@IsString()
@MinLength(5)
reason: string;
}

View File

@ -151,7 +151,8 @@ export class HubUsersService {
try { try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id); const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
const isMerchant = userRoles.some(role => role.name === UserRole.DCB_PARTNER); const isMerchant = userRoles.some(
role => role.name === UserRole.DCB_PARTNER || UserRole.DCB_PARTNER_ADMIN || UserRole.DCB_PARTNER_MANAGER || UserRole.DCB_PARTNER_SUPPORT);
if (isMerchant) { if (isMerchant) {
merchants.push(this.mapToHubUser(user, userRoles)); merchants.push(this.mapToHubUser(user, userRoles));

View File

@ -1,353 +0,0 @@
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { CreateUserData, UserRole, KeycloakUser } from '../../auth/services/keycloak-user.model';
export interface MerchantPartner {
id: string;
name: string;
legalName: string;
email: string;
phone?: string;
address?: string;
status: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
createdAt: Date;
updatedAt: Date;
dcbPartnerUserId: string; // ID du user DCB_PARTNER propriétaire
}
export interface CreateMerchantPartnerData {
name: string;
legalName: string;
email: string;
phone?: string;
address?: string;
dcbPartnerOwner: {
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
};
}
export interface MerchantStats {
totalMerchants: number;
activeMerchants: number;
suspendedMerchants: number;
pendingMerchants: number;
totalUsers: number;
}
@Injectable()
export class MerchantPartnersService {
private readonly logger = new Logger(MerchantPartnersService.name);
constructor(private readonly keycloakApi: KeycloakApiService) {}
// ===== CRÉATION D'UN MERCHANT PARTNER =====
async createMerchantPartner(
creatorId: string,
merchantData: CreateMerchantPartnerData
): Promise<MerchantPartner> {
this.logger.log(`Creating merchant partner: ${merchantData.name}`);
// Validation des permissions du créateur
await this.validateMerchantCreationPermissions(creatorId);
// Vérifier les doublons
await this.checkDuplicateMerchant(merchantData);
// 1. Créer le user DCB_PARTNER (propriétaire)
const dcbPartnerUserData: CreateUserData = {
username: merchantData.dcbPartnerOwner.username,
email: merchantData.dcbPartnerOwner.email,
firstName: merchantData.dcbPartnerOwner.firstName,
lastName: merchantData.dcbPartnerOwner.lastName,
password: merchantData.dcbPartnerOwner.password,
enabled: true,
emailVerified: true,
clientRoles: [UserRole.DCB_PARTNER],
createdBy: creatorId,
};
const dcbPartnerUserId = await this.keycloakApi.createUser(creatorId, dcbPartnerUserData);
// 2. Créer l'entité Merchant Partner (dans la base de données ou Keycloak attributes)
const merchantPartner: MerchantPartner = {
id: merchantData.dcbPartnerOwner.username,
name: merchantData.name,
legalName: merchantData.legalName,
email: merchantData.email,
phone: merchantData.phone,
address: merchantData.address,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
dcbPartnerUserId,
};
// 3. Stocker les infos du merchant dans les attributs du user DCB_PARTNER
await this.keycloakApi.setUserAttributes(dcbPartnerUserId, {
merchantPartnerName: [merchantData.name],
merchantLegalName: [merchantData.legalName],
merchantEmail: [merchantData.email],
merchantPhone: [merchantData.phone || ''],
merchantAddress: [merchantData.address || ''],
merchantStatus: ['ACTIVE'],
merchantCreatedAt: [new Date().toISOString()],
});
this.logger.log(`Merchant partner created successfully: ${merchantData.name}`);
return merchantPartner;
}
// ===== GESTION DES MERCHANTS =====
async getAllMerchantPartners(requesterId: string): Promise<MerchantPartner[]> {
await this.validateHubAccess(requesterId);
// Récupérer tous les users DCB_PARTNER
const allUsers = await this.keycloakApi.getAllUsers();
const merchants: MerchantPartner[] = [];
for (const user of allUsers) {
if (!user.id) continue;
try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
if (isDcbPartner && user.attributes?.merchantPartnerName?.[0]) {
merchants.push(this.mapToMerchantPartner(user));
}
} catch (error) {
this.logger.warn(`Could not process merchant user ${user.id}: ${error.message}`);
}
}
return merchants;
}
async getMerchantPartnerById(merchantId: string, requesterId: string): Promise<MerchantPartner> {
await this.validateMerchantAccess(requesterId, merchantId);
// Trouver le user DCB_PARTNER correspondant au merchant
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
return this.mapToMerchantPartner(dcbPartnerUser);
}
async updateMerchantPartner(
merchantId: string,
updates: Partial<{
name: string;
legalName: string;
email: string;
phone: string;
address: string;
status: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
}>,
requesterId: string
): Promise<MerchantPartner> {
await this.validateMerchantManagementPermissions(requesterId, merchantId);
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
// Mettre à jour les attributs
const attributes: any = {};
if (updates.name) attributes.merchantPartnerName = [updates.name];
if (updates.legalName) attributes.merchantLegalName = [updates.legalName];
if (updates.email) attributes.merchantEmail = [updates.email];
if (updates.phone) attributes.merchantPhone = [updates.phone];
if (updates.address) attributes.merchantAddress = [updates.address];
if (updates.status) attributes.merchantStatus = [updates.status];
attributes.merchantUpdatedAt = [new Date().toISOString()];
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, attributes);
return this.getMerchantPartnerById(merchantId, requesterId);
}
async suspendMerchantPartner(merchantId: string, reason: string, requesterId: string): Promise<void> {
await this.validateHubAccess(requesterId);
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
// Suspendre le merchant et tous ses utilisateurs
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, {
merchantStatus: ['SUSPENDED'],
suspensionReason: [reason],
suspendedAt: [new Date().toISOString()],
});
// Suspendre tous les utilisateurs du merchant
await this.suspendAllMerchantUsers(merchantId, requesterId);
this.logger.log(`Merchant partner suspended: ${merchantId}, reason: ${reason}`);
}
// ===== STATISTIQUES =====
async getMerchantStats(requesterId: string): Promise<MerchantStats> {
await this.validateHubAccess(requesterId);
const allMerchants = await this.getAllMerchantPartners(requesterId);
const allUsers = await this.keycloakApi.getAllUsers();
let totalUsers = 0;
for (const merchant of allMerchants) {
const merchantUsers = allUsers.filter(user =>
user.attributes?.merchantPartnerId?.[0] === merchant.id
);
totalUsers += merchantUsers.length;
}
return {
totalMerchants: allMerchants.length,
activeMerchants: allMerchants.filter(m => m.status === 'ACTIVE').length,
suspendedMerchants: allMerchants.filter(m => m.status === 'SUSPENDED').length,
pendingMerchants: allMerchants.filter(m => m.status === 'PENDING').length,
totalUsers,
};
}
// ===== MÉTHODES PRIVÉES =====
private async validateMerchantCreationPermissions(creatorId: string): Promise<void> {
const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId);
const canCreateMerchant = creatorRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (!canCreateMerchant) {
throw new ForbiddenException('Only hub administrators can create merchant partners');
}
}
private async validateHubAccess(requesterId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (!isHubAdmin) {
throw new ForbiddenException('Only hub administrators can access this resource');
}
}
private async validateMerchantAccess(requesterId: string, merchantId?: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
this.logger.debug(`Validating merchant access: requester=${requesterId}, merchant=${merchantId}`);
this.logger.debug(`Requester roles: ${requesterRoles.map(r => r.name).join(', ')}`);
// Les admins Hub ont accès complet
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (isHubAdmin) {
this.logger.debug('Hub admin access granted');
return;
}
// CORRECTION: Service account est considéré comme admin hub
if (requesterId.includes('service-account')) {
this.logger.debug('Service account access granted');
return;
}
// Les DCB_PARTNER n'ont accès qu'à leur propre merchant
if (requesterRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
if (merchantId && requesterId === merchantId) {
this.logger.debug('DCB_PARTNER access to own merchant granted');
return; // DCB_PARTNER accède à son propre merchant
}
throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner');
}
// Les autres rôles merchants n'ont accès qu'à leur merchant
const requesterMerchantId = await this.keycloakApi.getUserMerchantPartnerId(requesterId);
if (requesterMerchantId && merchantId && requesterMerchantId === merchantId) {
this.logger.debug('Merchant user access to own merchant granted');
return;
}
throw new ForbiddenException('Insufficient permissions to access this merchant');
}
private async validateMerchantManagementPermissions(requesterId: string, merchantId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
// Seuls les admins Hub peuvent modifier les merchants
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (!isHubAdmin) {
throw new ForbiddenException('Only hub administrators can manage merchant partners');
}
}
private async checkDuplicateMerchant(merchantData: CreateMerchantPartnerData): Promise<void> {
const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.dcbPartnerOwner.username);
if (existingUsers.length > 0) {
throw new BadRequestException(`Merchant partner with username ${merchantData.dcbPartnerOwner.username} already exists`);
}
const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.dcbPartnerOwner.email);
if (existingEmails.length > 0) {
throw new BadRequestException(`Merchant partner with email ${merchantData.dcbPartnerOwner.email} already exists`);
}
}
private async findDcbPartnerByMerchantId(merchantId: string): Promise<KeycloakUser> {
const users = await this.keycloakApi.findUserByUsername(merchantId);
if (users.length === 0) {
throw new NotFoundException(`Merchant partner not found: ${merchantId}`);
}
const user = users[0];
const userRoles = await this.keycloakApi.getUserClientRoles(user.id!);
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
if (!isDcbPartner) {
throw new NotFoundException(`User ${merchantId} is not a DCB_PARTNER`);
}
return user;
}
private mapToMerchantPartner(user: KeycloakUser): MerchantPartner {
return {
id: user.username,
name: user.attributes?.merchantPartnerName?.[0] || user.username,
legalName: user.attributes?.merchantLegalName?.[0] || '',
email: user.attributes?.merchantEmail?.[0] || user.email,
phone: user.attributes?.merchantPhone?.[0],
address: user.attributes?.merchantAddress?.[0],
status: (user.attributes?.merchantStatus?.[0] as 'ACTIVE' | 'SUSPENDED' | 'PENDING') || 'ACTIVE',
createdAt: user.attributes?.merchantCreatedAt?.[0]
? new Date(user.attributes.merchantCreatedAt[0])
: new Date(user.createdTimestamp || Date.now()),
updatedAt: user.attributes?.merchantUpdatedAt?.[0]
? new Date(user.attributes.merchantUpdatedAt[0])
: new Date(user.createdTimestamp || Date.now()),
dcbPartnerUserId: user.id!,
};
}
private async suspendAllMerchantUsers(merchantId: string, requesterId: string): Promise<void> {
const allUsers = await this.keycloakApi.getAllUsers();
const merchantUsers = allUsers.filter(user =>
user.attributes?.merchantPartnerId?.[0] === merchantId
);
for (const user of merchantUsers) {
if (user.id) {
try {
await this.keycloakApi.updateUser(user.id, { enabled: false }, requesterId);
} catch (error) {
this.logger.warn(`Could not suspend user ${user.id}: ${error.message}`);
}
}
}
}
}