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,71 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Creates the users table in PostgreSQL.
*
* Users were previously stored only in Redis (CacheUserRepository).
* This migration establishes PostgreSQL as the source of truth,
* enabling referential integrity, SQL queries, and data durability.
*/
final class Version20260214100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create users table in PostgreSQL for durable user storage';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE users (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL DEFAULT '',
last_name VARCHAR(100) NOT NULL DEFAULT '',
roles JSONB NOT NULL DEFAULT '[]',
hashed_password TEXT,
statut VARCHAR(30) NOT NULL DEFAULT 'pending',
school_name VARCHAR(255) NOT NULL DEFAULT '',
date_naissance DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
activated_at TIMESTAMPTZ,
invited_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
blocked_reason TEXT,
consentement_parent_id UUID,
consentement_eleve_id UUID,
consentement_date TIMESTAMPTZ,
consentement_ip VARCHAR(45),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, email)
)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_users_tenant ON users(tenant_id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_users_tenant_statut ON users(tenant_id, statut)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_users_created_at ON users(created_at)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
DROP TABLE IF EXISTS users
SQL);
}
}