Refatoring : Working state
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
104
src/User/Application/UserApplicationService.php
Normal file
104
src/User/Application/UserApplicationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/User/Domain/RolePolicy.php
Normal file
42
src/User/Domain/RolePolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/User/Http/UserController.php
Normal file
168
src/User/Http/UserController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
110
src/User/Infrastructure/PdoUserRepository.php
Normal file
110
src/User/Infrastructure/PdoUserRepository.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/**
|
||||
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
|
||||
* App\User\Http\UserController.
|
||||
*/
|
||||
final class UserController extends Http\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
|
||||
*/
|
||||
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, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,104 +3,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\User;
|
||||
|
||||
use PDO;
|
||||
use App\User\Infrastructure\PdoUserRepository;
|
||||
|
||||
final class UserRepository implements UserRepositoryInterface
|
||||
/**
|
||||
* 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 __construct(private readonly PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
use App\User\Application\UserApplicationService;
|
||||
|
||||
final class UserService implements UserServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function findAll(): array
|
||||
{
|
||||
return $this->userRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
$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;
|
||||
}
|
||||
final class UserService extends UserApplicationService implements UserServiceInterface
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user