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:
2026-02-15 14:39:17 +01:00
parent 76e16db0d8
commit a0e19627a7
15 changed files with 1581 additions and 4 deletions

View File

@@ -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,
);
}
}