Files
Classeo/backend/src/Administration/Infrastructure/Persistence/Cache/CacheUserRepository.php
Mathias STRASSER e930c505df feat: Attribution de rôles multiples par utilisateur
Les utilisateurs Classeo étaient limités à un seul rôle, alors que
dans la réalité scolaire un directeur peut aussi être enseignant,
ou un parent peut avoir un rôle vie scolaire. Cette limitation
obligeait à créer des comptes distincts par fonction.

Le modèle User supporte désormais plusieurs rôles simultanés avec
basculement via le header. L'admin peut attribuer/retirer des rôles
depuis l'interface de gestion, avec des garde-fous : pas d'auto-
destitution, pas d'escalade de privilèges (seul SUPER_ADMIN peut
attribuer SUPER_ADMIN), vérification du statut actif pour le
switch de rôle, et TTL explicite sur le cache de rôle actif.
2026-02-10 11:46:55 +01:00

243 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Cache;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\ConsentementParental\ConsentementParental;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\StatutCompte;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use function in_array;
use Psr\Cache\CacheItemPoolInterface;
use RuntimeException;
use function sprintf;
/**
* Cache-based UserRepository for development and testing.
* Uses PSR-6 cache (filesystem in dev, Redis in prod).
*
* Note: Uses a dedicated users.cache pool with no TTL to ensure
* user records don't expire (unlike activation tokens which expire after 7 days).
*/
final readonly class CacheUserRepository implements UserRepository
{
private const string KEY_PREFIX = 'user:';
private const string EMAIL_INDEX_PREFIX = 'user_email:';
private const string TENANT_INDEX_PREFIX = 'user_tenant:';
public function __construct(
private CacheItemPoolInterface $usersCache,
) {
}
public function save(User $user): void
{
// Save user data
$item = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
$item->set($this->serialize($user));
$this->usersCache->save($item);
// Save email index for lookup (scoped to tenant)
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
$emailItem->set((string) $user->id);
$this->usersCache->save($emailItem);
// Save tenant index for listing users
$tenantKey = self::TENANT_INDEX_PREFIX . $user->tenantId;
$tenantItem = $this->usersCache->getItem($tenantKey);
/** @var string[] $userIds */
$userIds = $tenantItem->isHit() ? $tenantItem->get() : [];
$userId = (string) $user->id;
if (!in_array($userId, $userIds, true)) {
$userIds[] = $userId;
}
$tenantItem->set($userIds);
$this->usersCache->save($tenantItem);
}
public function findById(UserId $id): ?User
{
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
if (!$item->isHit()) {
return null;
}
/** @var array{id: string, email: string, roles?: string[], role?: string, tenant_id: string, school_name: string, statut: string, hashed_password: string|null, date_naissance: string|null, created_at: string, activated_at: string|null, first_name?: string, last_name?: string, invited_at?: string|null, blocked_at?: string|null, blocked_reason?: string|null, consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null} $data */
$data = $item->get();
return $this->deserialize($data);
}
public function findByEmail(Email $email, TenantId $tenantId): ?User
{
$emailKey = $this->emailIndexKey($email, $tenantId);
$emailItem = $this->usersCache->getItem($emailKey);
if (!$emailItem->isHit()) {
return null;
}
/** @var string $userId */
$userId = $emailItem->get();
return $this->findById(UserId::fromString($userId));
}
public function get(UserId $id): User
{
$user = $this->findById($id);
if ($user === null) {
throw UserNotFoundException::withId($id);
}
return $user;
}
public function findAllByTenant(TenantId $tenantId): array
{
$tenantKey = self::TENANT_INDEX_PREFIX . $tenantId;
$tenantItem = $this->usersCache->getItem($tenantKey);
if (!$tenantItem->isHit()) {
return [];
}
/** @var string[] $userIds */
$userIds = $tenantItem->get();
$users = [];
foreach ($userIds as $userId) {
$user = $this->findById(UserId::fromString($userId));
if ($user !== null) {
$users[] = $user;
}
}
return $users;
}
/**
* @return array<string, mixed>
*/
private function serialize(User $user): array
{
$consentement = $user->consentementParental;
return [
'id' => (string) $user->id,
'email' => (string) $user->email,
'roles' => array_map(static fn (Role $r) => $r->value, $user->roles),
'tenant_id' => (string) $user->tenantId,
'school_name' => $user->schoolName,
'statut' => $user->statut->value,
'hashed_password' => $user->hashedPassword,
'date_naissance' => $user->dateNaissance?->format('Y-m-d'),
'created_at' => $user->createdAt->format('c'),
'activated_at' => $user->activatedAt?->format('c'),
'first_name' => $user->firstName,
'last_name' => $user->lastName,
'invited_at' => $user->invitedAt?->format('c'),
'blocked_at' => $user->blockedAt?->format('c'),
'blocked_reason' => $user->blockedReason,
'consentement_parental' => $consentement !== null ? [
'parent_id' => $consentement->parentId,
'eleve_id' => $consentement->eleveId,
'date_consentement' => $consentement->dateConsentement->format('c'),
'ip_address' => $consentement->ipAddress,
] : null,
];
}
/**
* @param array{
* id: string,
* email: string,
* roles?: string[],
* role?: string,
* tenant_id: string,
* school_name: string,
* statut: string,
* hashed_password: string|null,
* date_naissance: string|null,
* created_at: string,
* activated_at: string|null,
* first_name?: string,
* last_name?: string,
* invited_at?: string|null,
* blocked_at?: string|null,
* blocked_reason?: string|null,
* consentement_parental: array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null
* } $data
*/
private function deserialize(array $data): User
{
$consentement = null;
if ($data['consentement_parental'] !== null) {
$consentementData = $data['consentement_parental'];
$consentement = ConsentementParental::accorder(
parentId: $consentementData['parent_id'],
eleveId: $consentementData['eleve_id'],
at: new DateTimeImmutable($consentementData['date_consentement']),
ipAddress: $consentementData['ip_address'],
);
}
$invitedAt = ($data['invited_at'] ?? null) !== null ? new DateTimeImmutable($data['invited_at']) : null;
$blockedAt = ($data['blocked_at'] ?? null) !== null ? new DateTimeImmutable($data['blocked_at']) : null;
// Support both legacy single role and new multi-role format
$roleStrings = $data['roles'] ?? (isset($data['role']) ? [$data['role']] : []);
if ($roleStrings === []) {
throw new RuntimeException(sprintf('User %s has no roles in cache data.', $data['id']));
}
$roles = array_map(static fn (string $r) => Role::from($r), $roleStrings);
return User::reconstitute(
id: UserId::fromString($data['id']),
email: new Email($data['email']),
roles: $roles,
tenantId: TenantId::fromString($data['tenant_id']),
schoolName: $data['school_name'],
statut: StatutCompte::from($data['statut']),
dateNaissance: $data['date_naissance'] !== null ? new DateTimeImmutable($data['date_naissance']) : null,
createdAt: new DateTimeImmutable($data['created_at']),
hashedPassword: $data['hashed_password'],
activatedAt: $data['activated_at'] !== null ? new DateTimeImmutable($data['activated_at']) : null,
consentementParental: $consentement,
firstName: $data['first_name'] ?? '',
lastName: $data['last_name'] ?? '',
invitedAt: $invitedAt,
blockedAt: $blockedAt,
blockedReason: $data['blocked_reason'] ?? null,
);
}
private function normalizeEmail(Email $email): string
{
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
}
/**
* Creates a cache key for email lookup scoped to a tenant.
*/
private function emailIndexKey(Email $email, TenantId $tenantId): string
{
return self::EMAIL_INDEX_PREFIX . $tenantId . ':' . $this->normalizeEmail($email);
}
}