feat: Persister les utilisateurs en PostgreSQL avec cache-aside Redis
Les utilisateurs étaient stockés uniquement dans Redis (CacheUserRepository), ce qui exposait à une perte totale des comptes en cas de restart Redis, FLUSHDB ou perte du volume Docker. Les tables student_guardians et teacher_assignments référençaient des user IDs sans FK réelle. PostgreSQL devient la source de vérité via DoctrineUserRepository (DBAL, upsert ON CONFLICT). CachedUserRepository décore l'interface existante avec le pattern cache-aside : lectures Redis d'abord → miss → PostgreSQL → populate Redis ; écritures PostgreSQL d'abord → mise à jour Redis. Si Redis est indisponible, l'application continue via PostgreSQL seul. Une commande de migration (app:migrate-users-to-postgres) permet de copier les données Redis existantes vers PostgreSQL de manière idempotente.
This commit is contained in:
@@ -18,6 +18,8 @@ interface UserRepository
|
||||
*/
|
||||
public function get(UserId $id): User;
|
||||
|
||||
public function findById(UserId $id): ?User;
|
||||
|
||||
/**
|
||||
* Finds a user by email within a specific tenant.
|
||||
* Returns null if user doesn't exist in that tenant.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Console;
|
||||
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Infrastructure\Tenant\TenantRegistry;
|
||||
|
||||
use function count;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Migrates existing users from Redis cache to PostgreSQL.
|
||||
*
|
||||
* This command reads all users stored in Redis (via CacheUserRepository)
|
||||
* and persists them to PostgreSQL (via DoctrineUserRepository).
|
||||
* It is idempotent: re-running it will upsert existing records.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:migrate-users-to-postgres',
|
||||
description: 'Migrate existing users from Redis cache to PostgreSQL',
|
||||
)]
|
||||
final class MigrateUsersToPostgresCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $source,
|
||||
private readonly UserRepository $target,
|
||||
private readonly TenantRegistry $tenantRegistry,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->title('Migration des utilisateurs Redis → PostgreSQL');
|
||||
|
||||
$tenants = $this->tenantRegistry->getAllConfigs();
|
||||
$totalMigrated = 0;
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
$tenantId = $tenant->tenantId;
|
||||
$users = $this->source->findAllByTenant($tenantId);
|
||||
|
||||
if ($users === []) {
|
||||
$io->text("Tenant {$tenant->subdomain}: aucun utilisateur");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($users as $user) {
|
||||
$this->target->save($user);
|
||||
++$totalMigrated;
|
||||
}
|
||||
|
||||
$io->text("Tenant {$tenant->subdomain}: " . count($users) . ' utilisateur(s) migré(s)');
|
||||
}
|
||||
|
||||
$io->success("Migration terminée : {$totalMigrated} utilisateur(s) migré(s) vers PostgreSQL");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use DateTimeImmutable;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -66,6 +67,7 @@ final readonly class CacheUserRepository implements UserRepository
|
||||
$this->usersCache->save($tenantItem);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(UserId $id): ?User
|
||||
{
|
||||
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<?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 Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Cache-aside decorator around DoctrineUserRepository.
|
||||
*
|
||||
* Reads: Redis first, miss → PostgreSQL → populate Redis.
|
||||
* Writes: PostgreSQL first (source of truth) → update Redis.
|
||||
* If Redis is unavailable, falls back to PostgreSQL silently.
|
||||
*/
|
||||
final readonly class CachedUserRepository implements UserRepository
|
||||
{
|
||||
private const string KEY_PREFIX = 'user:';
|
||||
private const string EMAIL_INDEX_PREFIX = 'user_email:';
|
||||
|
||||
public function __construct(
|
||||
private UserRepository $inner,
|
||||
private CacheItemPoolInterface $usersCache,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(User $user): void
|
||||
{
|
||||
// 1. PostgreSQL first (source of truth)
|
||||
$this->inner->save($user);
|
||||
|
||||
// 2. Update Redis cache
|
||||
try {
|
||||
// Invalidate stale email index if the email changed
|
||||
$existingItem = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
|
||||
if ($existingItem->isHit()) {
|
||||
/** @var array<string, mixed> $oldData */
|
||||
$oldData = $existingItem->get();
|
||||
/** @var string $oldEmail */
|
||||
$oldEmail = $oldData['email'] ?? '';
|
||||
if ($oldEmail !== '' && $oldEmail !== (string) $user->email) {
|
||||
/** @var string $oldTenantId */
|
||||
$oldTenantId = $oldData['tenant_id'] ?? (string) $user->tenantId;
|
||||
$oldEmailKey = $this->emailIndexKey(
|
||||
new Email($oldEmail),
|
||||
TenantId::fromString($oldTenantId),
|
||||
);
|
||||
$this->usersCache->deleteItem($oldEmailKey);
|
||||
}
|
||||
}
|
||||
|
||||
$existingItem->set($this->serialize($user));
|
||||
$this->usersCache->save($existingItem);
|
||||
|
||||
// Email index
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
} catch (Throwable) {
|
||||
// Redis unavailable — PostgreSQL write succeeded, data is safe
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(UserId $id): User
|
||||
{
|
||||
$user = $this->findById($id);
|
||||
|
||||
if ($user === null) {
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(UserId $id): ?User
|
||||
{
|
||||
// 1. Try Redis
|
||||
try {
|
||||
$item = $this->usersCache->getItem(self::KEY_PREFIX . $id);
|
||||
if ($item->isHit()) {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = $item->get();
|
||||
|
||||
return $this->deserialize($data);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Redis unavailable, continue to PostgreSQL
|
||||
}
|
||||
|
||||
// 2. Fallback PostgreSQL
|
||||
$user = $this->inner->findById($id);
|
||||
|
||||
// 3. Populate cache
|
||||
if ($user !== null) {
|
||||
$this->populateCache($user);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
||||
{
|
||||
// 1. Try Redis email index
|
||||
try {
|
||||
$emailKey = $this->emailIndexKey($email, $tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
|
||||
if ($emailItem->isHit()) {
|
||||
/** @var string $userId */
|
||||
$userId = $emailItem->get();
|
||||
|
||||
return $this->findById(UserId::fromString($userId));
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Redis unavailable, continue to PostgreSQL
|
||||
}
|
||||
|
||||
// 2. Fallback PostgreSQL
|
||||
$user = $this->inner->findByEmail($email, $tenantId);
|
||||
|
||||
// 3. Populate cache
|
||||
if ($user !== null) {
|
||||
$this->populateCache($user);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findAllByTenant(TenantId $tenantId): array
|
||||
{
|
||||
// Always go to PostgreSQL for full list (cache may be incomplete)
|
||||
$users = $this->inner->findAllByTenant($tenantId);
|
||||
|
||||
// Populate cache for each user
|
||||
foreach ($users as $user) {
|
||||
$this->populateCache($user);
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
private function populateCache(User $user): void
|
||||
{
|
||||
try {
|
||||
$item = $this->usersCache->getItem(self::KEY_PREFIX . $user->id);
|
||||
$item->set($this->serialize($user));
|
||||
$this->usersCache->save($item);
|
||||
|
||||
// Email index
|
||||
$emailKey = $this->emailIndexKey($user->email, $user->tenantId);
|
||||
$emailItem = $this->usersCache->getItem($emailKey);
|
||||
$emailItem->set((string) $user->id);
|
||||
$this->usersCache->save($emailItem);
|
||||
} catch (Throwable) {
|
||||
// Redis unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<string, mixed> $data
|
||||
*/
|
||||
private function deserialize(array $data): User
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $data['id'];
|
||||
/** @var string $email */
|
||||
$email = $data['email'];
|
||||
// Support both legacy single role ('role') and multi-role ('roles') format
|
||||
/** @var string[] $roleStrings */
|
||||
$roleStrings = $data['roles'] ?? (isset($data['role']) ? [$data['role']] : []);
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $data['tenant_id'];
|
||||
/** @var string $schoolName */
|
||||
$schoolName = $data['school_name'];
|
||||
/** @var string $statut */
|
||||
$statut = $data['statut'];
|
||||
/** @var string|null $hashedPassword */
|
||||
$hashedPassword = $data['hashed_password'];
|
||||
/** @var string|null $dateNaissance */
|
||||
$dateNaissance = $data['date_naissance'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $data['created_at'];
|
||||
/** @var string|null $activatedAt */
|
||||
$activatedAt = $data['activated_at'];
|
||||
/** @var string $firstName */
|
||||
$firstName = $data['first_name'] ?? '';
|
||||
/** @var string $lastName */
|
||||
$lastName = $data['last_name'] ?? '';
|
||||
/** @var string|null $invitedAt */
|
||||
$invitedAt = $data['invited_at'] ?? null;
|
||||
/** @var string|null $blockedAt */
|
||||
$blockedAt = $data['blocked_at'] ?? null;
|
||||
/** @var string|null $blockedReason */
|
||||
$blockedReason = $data['blocked_reason'] ?? null;
|
||||
|
||||
$roles = array_map(static fn (string $r) => Role::from($r), $roleStrings);
|
||||
|
||||
$consentement = null;
|
||||
/** @var array{parent_id: string, eleve_id: string, date_consentement: string, ip_address: string}|null $consentementData */
|
||||
$consentementData = $data['consentement_parental'] ?? null;
|
||||
if ($consentementData !== null) {
|
||||
$consentement = ConsentementParental::accorder(
|
||||
parentId: $consentementData['parent_id'],
|
||||
eleveId: $consentementData['eleve_id'],
|
||||
at: new DateTimeImmutable($consentementData['date_consentement']),
|
||||
ipAddress: $consentementData['ip_address'],
|
||||
);
|
||||
}
|
||||
|
||||
return User::reconstitute(
|
||||
id: UserId::fromString($id),
|
||||
email: new Email($email),
|
||||
roles: $roles,
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
schoolName: $schoolName,
|
||||
statut: StatutCompte::from($statut),
|
||||
dateNaissance: $dateNaissance !== null ? new DateTimeImmutable($dateNaissance) : null,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
hashedPassword: $hashedPassword,
|
||||
activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null,
|
||||
consentementParental: $consentement,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
invitedAt: $invitedAt !== null ? new DateTimeImmutable($invitedAt) : null,
|
||||
blockedAt: $blockedAt !== null ? new DateTimeImmutable($blockedAt) : null,
|
||||
blockedReason: $blockedReason,
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeEmail(Email $email): string
|
||||
{
|
||||
return strtolower(str_replace(['@', '.'], ['_at_', '_dot_'], (string) $email));
|
||||
}
|
||||
|
||||
private function emailIndexKey(Email $email, TenantId $tenantId): string
|
||||
{
|
||||
return self::EMAIL_INDEX_PREFIX . $tenantId . ':' . $this->normalizeEmail($email);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
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 Doctrine\DBAL\Connection;
|
||||
|
||||
use function is_array;
|
||||
|
||||
use Override;
|
||||
use RuntimeException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final readonly class DoctrineUserRepository implements UserRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(User $user): void
|
||||
{
|
||||
$consentement = $user->consentementParental;
|
||||
|
||||
$this->connection->executeStatement(
|
||||
<<<'SQL'
|
||||
INSERT INTO users (
|
||||
id, tenant_id, email, first_name, last_name, roles,
|
||||
hashed_password, statut, school_name, date_naissance,
|
||||
created_at, activated_at, invited_at, blocked_at, blocked_reason,
|
||||
consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
:id, :tenant_id, :email, :first_name, :last_name, :roles,
|
||||
:hashed_password, :statut, :school_name, :date_naissance,
|
||||
:created_at, :activated_at, :invited_at, :blocked_at, :blocked_reason,
|
||||
:consentement_parent_id, :consentement_eleve_id, :consentement_date, :consentement_ip,
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_name = EXCLUDED.last_name,
|
||||
roles = EXCLUDED.roles,
|
||||
hashed_password = EXCLUDED.hashed_password,
|
||||
statut = EXCLUDED.statut,
|
||||
school_name = EXCLUDED.school_name,
|
||||
date_naissance = EXCLUDED.date_naissance,
|
||||
activated_at = EXCLUDED.activated_at,
|
||||
invited_at = EXCLUDED.invited_at,
|
||||
blocked_at = EXCLUDED.blocked_at,
|
||||
blocked_reason = EXCLUDED.blocked_reason,
|
||||
consentement_parent_id = EXCLUDED.consentement_parent_id,
|
||||
consentement_eleve_id = EXCLUDED.consentement_eleve_id,
|
||||
consentement_date = EXCLUDED.consentement_date,
|
||||
consentement_ip = EXCLUDED.consentement_ip,
|
||||
updated_at = NOW()
|
||||
SQL,
|
||||
[
|
||||
'id' => (string) $user->id,
|
||||
'tenant_id' => (string) $user->tenantId,
|
||||
'email' => (string) $user->email,
|
||||
'first_name' => $user->firstName,
|
||||
'last_name' => $user->lastName,
|
||||
'roles' => json_encode(array_map(static fn (Role $r) => $r->value, $user->roles)),
|
||||
'hashed_password' => $user->hashedPassword,
|
||||
'statut' => $user->statut->value,
|
||||
'school_name' => $user->schoolName,
|
||||
'date_naissance' => $user->dateNaissance?->format('Y-m-d'),
|
||||
'created_at' => $user->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'activated_at' => $user->activatedAt?->format(DateTimeImmutable::ATOM),
|
||||
'invited_at' => $user->invitedAt?->format(DateTimeImmutable::ATOM),
|
||||
'blocked_at' => $user->blockedAt?->format(DateTimeImmutable::ATOM),
|
||||
'blocked_reason' => $user->blockedReason,
|
||||
'consentement_parent_id' => $consentement?->parentId,
|
||||
'consentement_eleve_id' => $consentement?->eleveId,
|
||||
'consentement_date' => $consentement?->dateConsentement->format(DateTimeImmutable::ATOM),
|
||||
'consentement_ip' => $consentement?->ipAddress,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(UserId $id): User
|
||||
{
|
||||
$user = $this->findById($id);
|
||||
|
||||
if ($user === null) {
|
||||
throw UserNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(UserId $id): ?User
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM users WHERE id = :id',
|
||||
['id' => (string) $id],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM users WHERE tenant_id = :tenant_id AND email = :email',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'email' => (string) $email,
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findAllByTenant(TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM users WHERE tenant_id = :tenant_id ORDER BY created_at ASC',
|
||||
['tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
return array_map(fn (array $row) => $this->hydrate($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): User
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $email */
|
||||
$email = $row['email'];
|
||||
/** @var string $firstName */
|
||||
$firstName = $row['first_name'];
|
||||
/** @var string $lastName */
|
||||
$lastName = $row['last_name'];
|
||||
/** @var string $rolesJson */
|
||||
$rolesJson = $row['roles'];
|
||||
/** @var string|null $hashedPassword */
|
||||
$hashedPassword = $row['hashed_password'];
|
||||
/** @var string $statut */
|
||||
$statut = $row['statut'];
|
||||
/** @var string $schoolName */
|
||||
$schoolName = $row['school_name'];
|
||||
/** @var string|null $dateNaissance */
|
||||
$dateNaissance = $row['date_naissance'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string|null $activatedAt */
|
||||
$activatedAt = $row['activated_at'];
|
||||
/** @var string|null $invitedAt */
|
||||
$invitedAt = $row['invited_at'];
|
||||
/** @var string|null $blockedAt */
|
||||
$blockedAt = $row['blocked_at'];
|
||||
/** @var string|null $blockedReason */
|
||||
$blockedReason = $row['blocked_reason'];
|
||||
/** @var string|null $consentementParentId */
|
||||
$consentementParentId = $row['consentement_parent_id'];
|
||||
/** @var string|null $consentementEleveId */
|
||||
$consentementEleveId = $row['consentement_eleve_id'];
|
||||
/** @var string|null $consentementDate */
|
||||
$consentementDate = $row['consentement_date'];
|
||||
/** @var string|null $consentementIp */
|
||||
$consentementIp = $row['consentement_ip'];
|
||||
|
||||
/** @var string[]|null $roleValues */
|
||||
$roleValues = json_decode($rolesJson, true);
|
||||
|
||||
if (!is_array($roleValues)) {
|
||||
throw new RuntimeException(sprintf('Invalid roles JSON for user %s: %s', $id, $rolesJson));
|
||||
}
|
||||
|
||||
$roles = array_map(static fn (string $r) => Role::from($r), $roleValues);
|
||||
|
||||
$consentement = null;
|
||||
if ($consentementParentId !== null && $consentementEleveId !== null && $consentementDate !== null) {
|
||||
$consentement = ConsentementParental::accorder(
|
||||
parentId: $consentementParentId,
|
||||
eleveId: $consentementEleveId,
|
||||
at: new DateTimeImmutable($consentementDate),
|
||||
ipAddress: $consentementIp ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
return User::reconstitute(
|
||||
id: UserId::fromString($id),
|
||||
email: new Email($email),
|
||||
roles: $roles,
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
schoolName: $schoolName,
|
||||
statut: StatutCompte::from($statut),
|
||||
dateNaissance: $dateNaissance !== null ? new DateTimeImmutable($dateNaissance) : null,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
hashedPassword: $hashedPassword,
|
||||
activatedAt: $activatedAt !== null ? new DateTimeImmutable($activatedAt) : null,
|
||||
consentementParental: $consentement,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
invitedAt: $invitedAt !== null ? new DateTimeImmutable($invitedAt) : null,
|
||||
blockedAt: $blockedAt !== null ? new DateTimeImmutable($blockedAt) : null,
|
||||
blockedReason: $blockedReason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ final class InMemoryUserRepository implements UserRepository
|
||||
#[Override]
|
||||
public function get(UserId $id): User
|
||||
{
|
||||
$user = $this->byId[(string) $id] ?? null;
|
||||
$user = $this->findById($id);
|
||||
|
||||
if ($user === null) {
|
||||
throw UserNotFoundException::withId($id);
|
||||
@@ -39,6 +39,12 @@ final class InMemoryUserRepository implements UserRepository
|
||||
return $user;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(UserId $id): ?User
|
||||
{
|
||||
return $this->byId[(string) $id] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByEmail(Email $email, TenantId $tenantId): ?User
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user