Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un élève en cours d'année sans passer par un import CSV. Cette fonctionnalité pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui sera réutilisé par l'import CSV en masse (Story 3.1). L'email est désormais optionnel pour les élèves : si fourni, une invitation est envoyée (User::inviter) ; sinon l'élève est créé avec le statut INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur et l'affectation à la classe sont atomiques (transaction DBAL). Côté frontend, la page /admin/students offre liste paginée, recherche, filtrage par classe, création via modale (avec détection de doublons côté serveur), et changement de classe avec optimistic update.
268 lines
10 KiB
PHP
268 lines
10 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\ImageRightsStatus;
|
|
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 Override;
|
|
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)
|
|
if ($user->email !== null) {
|
|
$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);
|
|
}
|
|
|
|
#[Override]
|
|
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|null, 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, student_number?: 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;
|
|
}
|
|
|
|
#[Override]
|
|
public function findStudentsByTenant(TenantId $tenantId): array
|
|
{
|
|
return array_values(array_filter(
|
|
$this->findAllByTenant($tenantId),
|
|
static fn (User $user): bool => $user->aLeRole(Role::ELEVE),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function serialize(User $user): array
|
|
{
|
|
$consentement = $user->consentementParental;
|
|
|
|
return [
|
|
'id' => (string) $user->id,
|
|
'email' => $user->email !== null ? (string) $user->email : null,
|
|
'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,
|
|
'image_rights_status' => $user->imageRightsStatus->value,
|
|
'image_rights_updated_at' => $user->imageRightsUpdatedAt?->format('c'),
|
|
'image_rights_updated_by' => $user->imageRightsUpdatedBy !== null ? (string) $user->imageRightsUpdatedBy : null,
|
|
'student_number' => $user->studentNumber,
|
|
'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|null,
|
|
* 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,
|
|
* image_rights_status?: string,
|
|
* image_rights_updated_at?: string|null,
|
|
* image_rights_updated_by?: 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: $data['email'] !== null ? new Email($data['email']) : null,
|
|
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,
|
|
imageRightsStatus: isset($data['image_rights_status']) ? ImageRightsStatus::from($data['image_rights_status']) : ImageRightsStatus::NOT_SPECIFIED,
|
|
imageRightsUpdatedAt: ($data['image_rights_updated_at'] ?? null) !== null ? new DateTimeImmutable($data['image_rights_updated_at']) : null,
|
|
imageRightsUpdatedBy: ($data['image_rights_updated_by'] ?? null) !== null ? UserId::fromString($data['image_rights_updated_by']) : null,
|
|
studentNumber: $data['student_number'] ?? 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);
|
|
}
|
|
}
|