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.
236 lines
8.9 KiB
PHP
236 lines
8.9 KiB
PHP
<?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,
|
|
);
|
|
}
|
|
}
|