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

@@ -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.

View File

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

View File

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

View File

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

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

View File

@@ -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
{