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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user