Refatoring : Working state

This commit is contained in:
julien
2026-03-16 14:21:31 +01:00
parent d0761ff010
commit d832d598fe
10 changed files with 449 additions and 358 deletions

View File

@@ -50,9 +50,9 @@ use App\Shared\Http\SessionManager;
use App\Shared\Http\SessionManagerInterface;
use App\Shared\Mail\MailService;
use App\Shared\Mail\MailServiceInterface;
use App\User\UserRepository;
use App\User\Application\UserApplicationService;
use App\User\Infrastructure\PdoUserRepository;
use App\User\UserRepositoryInterface;
use App\User\UserService;
use App\User\UserServiceInterface;
use Monolog\Handler\StreamHandler;
@@ -70,12 +70,12 @@ return [
AuthServiceInterface::class => autowire(AuthService::class),
PostServiceInterface::class => autowire(PostApplicationService::class),
UserServiceInterface::class => autowire(UserService::class),
UserServiceInterface::class => autowire(UserApplicationService::class),
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
CategoryRepositoryInterface::class => autowire(PdoCategoryRepository::class),
MediaRepositoryInterface::class => autowire(MediaRepository::class),
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
UserRepositoryInterface::class => autowire(UserRepository::class),
UserRepositoryInterface::class => autowire(PdoUserRepository::class),
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
PasswordResetRepositoryInterface::class => autowire(PasswordResetRepository::class),
PasswordResetServiceInterface::class => autowire(PasswordResetService::class),

View File

@@ -1,8 +1,8 @@
# Architecture
> **Refactor DDD légère — lot 1**
> **Refactor DDD légère — lots 1 et 2**
>
> `Post/` et `Category/` introduisent maintenant une organisation verticale
> `Post/`, `Category/` et `User/` introduisent maintenant une organisation verticale
> `Application / Infrastructure / Http / Domain` pour alléger la lecture et préparer
> un découpage plus fin par cas d'usage. Les classes historiques à la racine du domaine
> sont conservées comme **ponts de compatibilité** afin de préserver les routes, le conteneur
@@ -83,8 +83,8 @@ final class PostService
| Interface | Implémentation | Domaine |
|------------------------------------|---------------------------|------------|
| `UserRepositoryInterface` | `UserRepository` | `User/` |
| `UserServiceInterface` | `UserService` | `User/` |
| `UserRepositoryInterface` | `PdoUserRepository` | `User/` |
| `UserServiceInterface` | `UserApplicationService` | `User/` |
| `LoginAttemptRepositoryInterface` | `LoginAttemptRepository` | `Auth/` |
| `PasswordResetRepositoryInterface` | `PasswordResetRepository` | `Auth/` |
| `PasswordResetServiceInterface` | `PasswordResetService` | `Auth/` |

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\User\Application;
use App\Shared\Exception\NotFoundException;
use App\Shared\Pagination\PaginatedResult;
use App\User\Domain\RolePolicy;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\WeakPasswordException;
use App\User\User;
use App\User\UserRepositoryInterface;
use App\User\UserServiceInterface;
class UserApplicationService implements UserServiceInterface
{
private readonly RolePolicy $rolePolicy;
public function __construct(
private readonly UserRepositoryInterface $userRepository,
?RolePolicy $rolePolicy = null,
) {
$this->rolePolicy = $rolePolicy ?? new RolePolicy();
}
/** @return User[] */
public function findAll(): array
{
return $this->userRepository->findAll();
}
/** @return PaginatedResult<User> */
public function findPaginated(int $page, int $perPage): PaginatedResult
{
$page = max(1, $page);
$total = $this->userRepository->countAll();
$offset = ($page - 1) * $perPage;
return new PaginatedResult(
$this->userRepository->findPage($perPage, $offset),
$total,
$page,
$perPage,
);
}
public function findById(int $id): ?User
{
return $this->userRepository->findById($id);
}
public function delete(int $id): void
{
$this->requireExistingUser($id);
$this->userRepository->delete($id);
}
public function updateRole(int $id, string $role): void
{
$this->rolePolicy->assertAssignable($role);
$this->requireExistingUser($id);
$this->userRepository->updateRole($id, $role);
}
public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User
{
$username = mb_strtolower(trim($username));
$email = mb_strtolower(trim($email));
$plainPassword = trim($plainPassword);
$this->rolePolicy->assertAssignable($role);
if ($this->userRepository->findByUsername($username)) {
throw new DuplicateUsernameException($username);
}
if ($this->userRepository->findByEmail($email)) {
throw new DuplicateEmailException($email);
}
if (mb_strlen($plainPassword) < 8) {
throw new WeakPasswordException();
}
$passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
$user = new User(0, $username, $email, $passwordHash, $role);
$this->userRepository->create($user);
return $user;
}
private function requireExistingUser(int $id): User
{
$user = $this->userRepository->findById($id);
if ($user === null) {
throw new NotFoundException('Utilisateur', $id);
}
return $user;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\User\Domain;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\RoleAssignmentNotAllowedException;
use App\User\User;
class RolePolicy
{
/**
* @return string[]
*/
public function allRoles(): array
{
return User::allRoles();
}
/**
* @return string[]
*/
public function assignableRoles(): array
{
return User::assignableRoles();
}
/**
* @throws InvalidRoleException
* @throws RoleAssignmentNotAllowedException
*/
public function assertAssignable(string $role): void
{
if (!in_array($role, $this->allRoles(), true)) {
throw new InvalidRoleException($role);
}
if (!in_array($role, $this->assignableRoles(), true)) {
throw new RoleAssignmentNotAllowedException($role);
}
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\User\Http;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginationPresenter;
use App\User\Domain\RolePolicy;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\RoleAssignmentNotAllowedException;
use App\User\Exception\WeakPasswordException;
use App\User\UserServiceInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
class UserController
{
private const PER_PAGE = 15;
private readonly RolePolicy $rolePolicy;
public function __construct(
private readonly Twig $view,
private readonly UserServiceInterface $userService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
?RolePolicy $rolePolicy = null,
) {
$this->rolePolicy = $rolePolicy ?? new RolePolicy();
}
public function index(Request $req, Response $res): Response
{
$page = PaginationPresenter::resolvePage($req->getQueryParams());
$paginated = $this->userService->findPaginated($page, self::PER_PAGE);
return $this->view->render($res, 'admin/users/index.twig', [
'users' => $paginated->getItems(),
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
'currentUserId' => $this->sessionManager->getUserId(),
'assignableRoles' => $this->rolePolicy->assignableRoles(),
'error' => $this->flash->get('user_error'),
'success' => $this->flash->get('user_success'),
]);
}
public function showCreate(Request $req, Response $res): Response
{
return $this->view->render($res, 'admin/users/form.twig', [
'assignableRoles' => $this->rolePolicy->assignableRoles(),
'error' => $this->flash->get('user_error'),
]);
}
public function create(Request $req, Response $res): Response
{
$data = (array) $req->getParsedBody();
$username = trim((string) ($data['username'] ?? ''));
$email = trim((string) ($data['email'] ?? ''));
$password = trim((string) ($data['password'] ?? ''));
$confirm = trim((string) ($data['password_confirm'] ?? ''));
$rawRole = trim((string) ($data['role'] ?? ''));
$role = in_array($rawRole, $this->rolePolicy->assignableRoles(), true) ? $rawRole : 'user';
if ($password !== $confirm) {
$this->flash->set('user_error', 'Les mots de passe ne correspondent pas');
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
}
try {
$this->userService->createUser($username, $email, $password, $role);
$this->flash->set('user_success', "L'utilisateur « {$username} » a été créé avec succès");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
} catch (DuplicateUsernameException) {
$this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris");
} catch (DuplicateEmailException) {
$this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée');
} catch (WeakPasswordException) {
$this->flash->set('user_error', 'Le mot de passe doit contenir au moins 8 caractères');
} catch (InvalidRoleException | RoleAssignmentNotAllowedException $e) {
$this->flash->set('user_error', $e->getMessage());
} catch (\Throwable) {
$this->flash->set('user_error', "Une erreur inattendue s'est produite");
}
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
}
/** @param array<string, mixed> $args */
public function updateRole(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
$user = $this->userService->findById($id);
if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié depuis l\'interface');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$body = (array) $req->getParsedBody();
$rawRole = trim((string) ($body['role'] ?? ''));
$role = in_array($rawRole, $this->rolePolicy->assignableRoles(), true) ? $rawRole : null;
if ($role === null) {
$this->flash->set('user_error', 'Rôle invalide');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
try {
$this->userService->updateRole($id, $role);
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour");
} catch (InvalidRoleException | RoleAssignmentNotAllowedException $e) {
$this->flash->set('user_error', $e->getMessage());
}
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
/** @param array<string, mixed> $args */
public function delete(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
$user = $this->userService->findById($id);
if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$this->userService->delete($id);
$this->flash->set('user_success', "L'utilisateur « {$user->getUsername()} » a été supprimé avec succès");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\User\Infrastructure;
use App\User\User;
use App\User\UserRepositoryInterface;
use PDO;
class PdoUserRepository implements UserRepositoryInterface
{
public function __construct(private readonly PDO $db)
{
}
/** @return User[] */
public function findAll(): array
{
$stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur users a échoué.');
}
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
/** @return User[] */
public function findPage(int $limit, int $offset): array
{
$stmt = $this->db->prepare('SELECT * FROM users ORDER BY created_at ASC LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countAll(): int
{
$stmt = $this->db->query('SELECT COUNT(*) FROM users');
if ($stmt === false) {
throw new \RuntimeException('La requête COUNT sur users a échoué.');
}
return (int) ($stmt->fetchColumn() ?: 0);
}
public function findById(int $id): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function findByUsername(string $username): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function findByEmail(string $email): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function create(User $user): int
{
$stmt = $this->db->prepare(
'INSERT INTO users (username, email, password_hash, role, created_at)
VALUES (:username, :email, :password_hash, :role, :created_at)'
);
$stmt->execute([
':username' => $user->getUsername(),
':email' => $user->getEmail(),
':password_hash' => $user->getPasswordHash(),
':role' => $user->getRole(),
':created_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->lastInsertId();
}
public function updatePassword(int $id, string $newHash): void
{
$stmt = $this->db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id');
$stmt->execute([':password_hash' => $newHash, ':id' => $id]);
}
public function updateRole(int $id, string $role): void
{
$stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id');
$stmt->execute([':role' => $role, ':id' => $id]);
}
public function delete(int $id): void
{
$stmt = $this->db->prepare('DELETE FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}

View File

@@ -3,155 +3,10 @@ declare(strict_types=1);
namespace App\User;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use App\Shared\Pagination\PaginationPresenter;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\RoleAssignmentNotAllowedException;
use App\User\Exception\WeakPasswordException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Views\Twig;
final class UserController
{
private const PER_PAGE = 15;
public function __construct(
private readonly Twig $view,
private readonly UserServiceInterface $userService,
private readonly FlashServiceInterface $flash,
private readonly SessionManagerInterface $sessionManager,
) {
}
public function index(Request $req, Response $res): Response
{
$page = PaginationPresenter::resolvePage($req->getQueryParams());
$paginated = $this->userService->findPaginated($page, self::PER_PAGE);
return $this->view->render($res, 'admin/users/index.twig', [
'users' => $paginated->getItems(),
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
'currentUserId' => $this->sessionManager->getUserId(),
'assignableRoles' => User::assignableRoles(),
'error' => $this->flash->get('user_error'),
'success' => $this->flash->get('user_success'),
]);
}
public function showCreate(Request $req, Response $res): Response
{
return $this->view->render($res, 'admin/users/form.twig', [
'assignableRoles' => User::assignableRoles(),
'error' => $this->flash->get('user_error'),
]);
}
public function create(Request $req, Response $res): Response
{
$data = (array) $req->getParsedBody();
$username = trim((string) ($data['username'] ?? ''));
$email = trim((string) ($data['email'] ?? ''));
$password = trim((string) ($data['password'] ?? ''));
$confirm = trim((string) ($data['password_confirm'] ?? ''));
$rawRole = trim((string) ($data['role'] ?? ''));
$role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : User::ROLE_USER;
if ($password !== $confirm) {
$this->flash->set('user_error', 'Les mots de passe ne correspondent pas');
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
}
try {
$this->userService->createUser($username, $email, $password, $role);
$this->flash->set('user_success', "L'utilisateur « {$username} » a été créé avec succès");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
} catch (DuplicateUsernameException) {
$this->flash->set('user_error', "Ce nom d'utilisateur est déjà pris");
} catch (DuplicateEmailException) {
$this->flash->set('user_error', 'Cette adresse e-mail est déjà utilisée');
} catch (WeakPasswordException) {
$this->flash->set('user_error', 'Le mot de passe doit contenir au moins 8 caractères');
} catch (InvalidRoleException|RoleAssignmentNotAllowedException $e) {
$this->flash->set('user_error', $e->getMessage());
} catch (\Throwable) {
$this->flash->set('user_error', "Une erreur inattendue s'est produite");
}
return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
}
/**
* @param array<string, mixed> $args
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
* App\User\Http\UserController.
*/
public function updateRole(Request $req, Response $res, array $args): Response
final class UserController extends Http\UserController
{
$id = (int) $args['id'];
$user = $this->userService->findById($id);
if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas modifier votre propre rôle');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le rôle d\'un administrateur ne peut pas être modifié depuis l\'interface');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$body = (array) $req->getParsedBody();
$rawRole = trim((string) ($body['role'] ?? ''));
$role = in_array($rawRole, User::assignableRoles(), true) ? $rawRole : null;
if ($role === null) {
$this->flash->set('user_error', 'Rôle invalide');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
try {
$this->userService->updateRole($id, $role);
$this->flash->set('user_success', "Le rôle de « {$user->getUsername()} » a été mis à jour");
} catch (InvalidRoleException|RoleAssignmentNotAllowedException $e) {
$this->flash->set('user_error', $e->getMessage());
}
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
/**
* @param array<string, mixed> $args
*/
public function delete(Request $req, Response $res, array $args): Response
{
$id = (int) $args['id'];
$user = $this->userService->findById($id);
if ($user === null) {
$this->flash->set('user_error', 'Utilisateur introuvable');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($user->isAdmin()) {
$this->flash->set('user_error', 'Le compte administrateur ne peut pas être supprimé');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
if ($id === $this->sessionManager->getUserId()) {
$this->flash->set('user_error', 'Vous ne pouvez pas supprimer votre propre compte');
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
$this->userService->delete($id);
$this->flash->set('user_success', "L'utilisateur « {$user->getUsername()} » a été supprimé avec succès");
return $res->withHeader('Location', '/admin/users')->withStatus(302);
}
}

View File

@@ -3,104 +3,12 @@ declare(strict_types=1);
namespace App\User;
use PDO;
use App\User\Infrastructure\PdoUserRepository;
final class UserRepository implements UserRepositoryInterface
{
public function __construct(private readonly PDO $db)
/**
* Pont de compatibilité : l'implémentation PDO principale vit désormais dans
* App\User\Infrastructure\PdoUserRepository.
*/
final class UserRepository extends PdoUserRepository implements UserRepositoryInterface
{
}
public function findAll(): array
{
$stmt = $this->db->query('SELECT * FROM users ORDER BY created_at ASC');
if ($stmt === false) {
throw new \RuntimeException('La requête SELECT sur users a échoué.');
}
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function findPage(int $limit, int $offset): array
{
$stmt = $this->db->prepare('SELECT * FROM users ORDER BY created_at ASC LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return array_map(fn ($row) => User::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
}
public function countAll(): int
{
$stmt = $this->db->query('SELECT COUNT(*) FROM users');
if ($stmt === false) {
throw new \RuntimeException('La requête COUNT sur users a échoué.');
}
return (int) ($stmt->fetchColumn() ?: 0);
}
public function findById(int $id): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function findByUsername(string $username): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE username = :username');
$stmt->execute([':username' => $username]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function findByEmail(string $email): ?User
{
$stmt = $this->db->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? User::fromArray($row) : null;
}
public function create(User $user): int
{
$stmt = $this->db->prepare(
'INSERT INTO users (username, email, password_hash, role, created_at)
VALUES (:username, :email, :password_hash, :role, :created_at)'
);
$stmt->execute([
':username' => $user->getUsername(),
':email' => $user->getEmail(),
':password_hash' => $user->getPasswordHash(),
':role' => $user->getRole(),
':created_at' => date('Y-m-d H:i:s'),
]);
return (int) $this->db->lastInsertId();
}
public function updatePassword(int $id, string $newHash): void
{
$stmt = $this->db->prepare('UPDATE users SET password_hash = :password_hash WHERE id = :id');
$stmt->execute([':password_hash' => $newHash, ':id' => $id]);
}
public function updateRole(int $id, string $role): void
{
$stmt = $this->db->prepare('UPDATE users SET role = :role WHERE id = :id');
$stmt->execute([':role' => $role, ':id' => $id]);
}
public function delete(int $id): void
{
$stmt = $this->db->prepare('DELETE FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}

View File

@@ -3,108 +3,12 @@ declare(strict_types=1);
namespace App\User;
use App\Shared\Exception\NotFoundException;
use App\Shared\Pagination\PaginatedResult;
use App\User\Exception\DuplicateEmailException;
use App\User\Exception\DuplicateUsernameException;
use App\User\Exception\InvalidRoleException;
use App\User\Exception\RoleAssignmentNotAllowedException;
use App\User\Exception\WeakPasswordException;
final class UserService implements UserServiceInterface
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
) {
}
public function findAll(): array
{
return $this->userRepository->findAll();
}
use App\User\Application\UserApplicationService;
/**
* @return PaginatedResult<User>
* Pont de compatibilité : l'implémentation métier principale vit désormais dans
* App\User\Application\UserApplicationService.
*/
public function findPaginated(int $page, int $perPage): PaginatedResult
final class UserService extends UserApplicationService implements UserServiceInterface
{
$page = max(1, $page);
$total = $this->userRepository->countAll();
$offset = ($page - 1) * $perPage;
return new PaginatedResult(
$this->userRepository->findPage($perPage, $offset),
$total,
$page,
$perPage,
);
}
public function findById(int $id): ?User
{
return $this->userRepository->findById($id);
}
public function delete(int $id): void
{
$this->requireExistingUser($id);
$this->userRepository->delete($id);
}
public function updateRole(int $id, string $role): void
{
$this->assertRoleCanBeAssigned($role);
$this->requireExistingUser($id);
$this->userRepository->updateRole($id, $role);
}
public function createUser(string $username, string $email, string $plainPassword, string $role = User::ROLE_USER): User
{
$username = mb_strtolower(trim($username));
$email = mb_strtolower(trim($email));
$plainPassword = trim($plainPassword);
$this->assertRoleCanBeAssigned($role);
if ($this->userRepository->findByUsername($username)) {
throw new DuplicateUsernameException($username);
}
if ($this->userRepository->findByEmail($email)) {
throw new DuplicateEmailException($email);
}
if (mb_strlen($plainPassword) < 8) {
throw new WeakPasswordException();
}
$passwordHash = password_hash($plainPassword, PASSWORD_BCRYPT, ['cost' => 12]);
$user = new User(0, $username, $email, $passwordHash, $role);
$this->userRepository->create($user);
return $user;
}
private function assertRoleCanBeAssigned(string $role): void
{
if (!in_array($role, User::allRoles(), true)) {
throw new InvalidRoleException($role);
}
if (!in_array($role, User::assignableRoles(), true)) {
throw new RoleAssignmentNotAllowedException($role);
}
}
private function requireExistingUser(int $id): User
{
$user = $this->userRepository->findById($id);
if ($user === null) {
throw new NotFoundException('Utilisateur', $id);
}
return $user;
}
}