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.
243 lines
8.7 KiB
PHP
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);
|
|
}
|
|
}
|