feat: Provisionner automatiquement un nouvel établissement
Some checks failed
CI / Naming Conventions (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
This commit is contained in:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit 713e408773
65 changed files with 5070 additions and 374 deletions

View File

@@ -12,4 +12,9 @@ interface FileStorage
public function upload(string $path, mixed $content, string $mimeType): string;
public function delete(string $path): void;
/**
* @return resource
*/
public function readStream(string $path): mixed;
}

View File

@@ -6,22 +6,26 @@ namespace App\Scolarite\Application\Query\GetBlockedDates;
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Scolarite\Application\Port\HomeworkRulesChecker;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateInterval;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends)
* pour une plage de dates donnée.
* Retourne les dates bloquées (jours fériés, vacances, journées pédagogiques, weekends,
* et dates non conformes aux règles de devoirs) pour une plage de dates donnée.
*
* Utilisé par le frontend pour griser les jours non modifiables dans la grille EDT.
* Utilisé par le frontend pour griser les jours non disponibles dans le calendrier.
*/
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetBlockedDatesHandler
{
public function __construct(
private SchoolCalendarRepository $calendarRepository,
private HomeworkRulesChecker $rulesChecker,
private Clock $clock,
) {
}
@@ -37,6 +41,7 @@ final readonly class GetBlockedDatesHandler
$endDate = new DateTimeImmutable($query->endDate);
$oneDay = new DateInterval('P1D');
$now = $this->clock->now();
$blockedDates = [];
$current = $startDate;
@@ -50,14 +55,21 @@ final readonly class GetBlockedDatesHandler
reason: $dayOfWeek === 6 ? 'Samedi' : 'Dimanche',
type: 'weekend',
);
} elseif ($calendar !== null) {
$entry = $calendar->trouverEntreePourDate($current);
} elseif ($calendar !== null && ($entry = $calendar->trouverEntreePourDate($current)) !== null) {
$blockedDates[] = new BlockedDateDto(
date: $dateStr,
reason: $entry->label,
type: $entry->type->value,
);
} else {
$dueDate = new DateTimeImmutable($dateStr);
$result = $this->rulesChecker->verifier($tenantId, $dueDate, $now);
if ($entry !== null) {
if (!$result->estValide()) {
$blockedDates[] = new BlockedDateDto(
date: $dateStr,
reason: $entry->label,
type: $entry->type->value,
reason: $result->messages()[0] ?? 'Règle de devoirs',
type: $result->estBloquant() ? 'rule_hard' : 'rule_soft',
);
}
}

View File

@@ -16,16 +16,16 @@ use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function realpath;
use function str_starts_with;
use function fclose;
use function fpassthru;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -39,8 +39,6 @@ final readonly class HomeworkAttachmentController
private HomeworkAttachmentRepository $attachmentRepository,
private UploadHomeworkAttachmentHandler $uploadHandler,
private FileStorage $fileStorage,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $storageDir,
) {
}
@@ -124,7 +122,7 @@ final readonly class HomeworkAttachmentController
}
#[Route('/api/homework/{id}/attachments/{attachmentId}', name: 'api_homework_attachment_download', methods: ['GET'])]
public function download(string $id, string $attachmentId): BinaryFileResponse
public function download(string $id, string $attachmentId): StreamedResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
@@ -143,20 +141,29 @@ final readonly class HomeworkAttachmentController
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->storageDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realStorageDir = realpath($this->storageDir);
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
try {
$stream = $this->fileStorage->readStream($attachment->filePath);
} catch (RuntimeException) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$response = new StreamedResponse(static function () use ($stream): void {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename,
);
$response->headers->set('Content-Type', $attachment->mimeType);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Length', (string) $attachment->fileSize);
return $response;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Application\Query\GetChildrenHomework\ChildHomeworkDto;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
@@ -18,16 +19,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkParentVoter;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function fclose;
use function fpassthru;
use function is_string;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +46,7 @@ final readonly class ParentHomeworkController
private GetChildrenHomeworkDetailHandler $detailHandler,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $uploadsDir,
private FileStorage $fileStorage,
) {
}
@@ -116,7 +116,7 @@ final readonly class ParentHomeworkController
* Téléchargement d'une pièce jointe (parent).
*/
#[Route('/api/me/children/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_parent_child_homework_attachment', methods: ['GET'])]
public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse
public function downloadAttachment(string $homeworkId, string $attachmentId): StreamedResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
@@ -138,20 +138,29 @@ final readonly class ParentHomeworkController
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realUploadsDir = realpath($this->uploadsDir);
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
try {
$stream = $this->fileStorage->readStream($attachment->filePath);
} catch (RuntimeException) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$response = new StreamedResponse(static function () use ($stream): void {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename,
);
$response->headers->set('Content-Type', $attachment->mimeType);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Length', (string) $attachment->fileSize);
return $response;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
@@ -19,16 +20,16 @@ use App\Scolarite\Infrastructure\Security\HomeworkStudentVoter;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function fclose;
use function fpassthru;
use function is_string;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
@@ -44,8 +45,7 @@ final readonly class StudentHomeworkController
private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader,
private StudentClassReader $studentClassReader,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $uploadsDir,
private FileStorage $fileStorage,
) {
}
@@ -98,7 +98,7 @@ final readonly class StudentHomeworkController
}
#[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])]
public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse
public function downloadAttachment(string $homeworkId, string $attachmentId): StreamedResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
@@ -115,20 +115,29 @@ final readonly class StudentHomeworkController
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->uploadsDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realUploadsDir = realpath($this->uploadsDir);
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
try {
$stream = $this->fileStorage->readStream($attachment->filePath);
} catch (RuntimeException) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$response = new StreamedResponse(static function () use ($stream): void {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename,
);
$response->headers->set('Content-Type', $attachment->mimeType);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Length', (string) $attachment->fileSize);
return $response;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\ClassStudentsReader;
use App\Scolarite\Application\Port\FileStorage;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmission;
use App\Scolarite\Domain\Model\HomeworkSubmission\HomeworkSubmissionId;
@@ -24,15 +25,15 @@ use function count;
use DateTimeImmutable;
use function fclose;
use function fpassthru;
use function in_array;
use function realpath;
use function str_starts_with;
use RuntimeException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
@@ -45,8 +46,7 @@ final readonly class TeacherSubmissionController
private HomeworkSubmissionRepository $submissionRepository,
private SubmissionAttachmentRepository $attachmentRepository,
private ClassStudentsReader $classStudentsReader,
#[Autowire('%kernel.project_dir%/var/storage')]
private string $storageDir,
private FileStorage $fileStorage,
) {
}
@@ -240,7 +240,7 @@ final readonly class TeacherSubmissionController
}
#[Route('/api/homework/{homeworkId}/submissions/{submissionId}/attachments/{attachmentId}', name: 'api_teacher_submission_attachment_download', methods: ['GET'])]
public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): BinaryFileResponse
public function downloadAttachment(string $homeworkId, string $submissionId, string $attachmentId): StreamedResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
@@ -268,20 +268,29 @@ final readonly class TeacherSubmissionController
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$fullPath = $this->storageDir . '/' . $attachment->filePath;
$realPath = realpath($fullPath);
$realStorageDir = realpath($this->storageDir);
if ($realPath === false || $realStorageDir === false || !str_starts_with($realPath, $realStorageDir)) {
try {
$stream = $this->fileStorage->readStream($attachment->filePath);
} catch (RuntimeException) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$response = new StreamedResponse(static function () use ($stream): void {
try {
fpassthru($stream);
} finally {
fclose($stream);
}
});
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
$attachment->filename,
);
$response->headers->set('Content-Type', $attachment->mimeType);
$response->headers->set('Content-Disposition', $disposition);
$response->headers->set('Content-Length', (string) $attachment->fileSize);
return $response;
}
}

View File

@@ -8,6 +8,7 @@ use App\Scolarite\Application\Port\FileStorage;
use function dirname;
use function file_put_contents;
use function fopen;
use function is_dir;
use function is_file;
use function is_string;
@@ -15,6 +16,12 @@ use function mkdir;
use Override;
use function realpath;
use RuntimeException;
use function sprintf;
use function str_starts_with;
use function unlink;
final readonly class LocalFileStorage implements FileStorage
@@ -50,4 +57,24 @@ final readonly class LocalFileStorage implements FileStorage
unlink($fullPath);
}
}
#[Override]
public function readStream(string $path): mixed
{
$fullPath = $this->storagePath . '/' . $path;
$realPath = realpath($fullPath);
$realStoragePath = realpath($this->storagePath);
if ($realPath === false || $realStoragePath === false || !str_starts_with($realPath, $realStoragePath)) {
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path));
}
$stream = fopen($realPath, 'r');
if ($stream === false) {
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path));
}
return $stream;
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Storage;
use App\Scolarite\Application\Port\FileStorage;
use Aws\S3\S3Client;
use function is_resource;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\Filesystem;
use League\Flysystem\UnableToDeleteFile;
use League\Flysystem\UnableToReadFile;
use Override;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use RuntimeException;
use function sprintf;
final readonly class S3FileStorage implements FileStorage
{
private Filesystem $filesystem;
private LoggerInterface $logger;
public function __construct(
string $endpoint,
string $bucket,
string $key,
string $secret,
string $region,
?LoggerInterface $logger = null,
) {
$this->logger = $logger ?? new NullLogger();
$client = new S3Client([
'endpoint' => $endpoint,
'credentials' => [
'key' => $key,
'secret' => $secret,
],
'region' => $region,
'version' => 'latest',
'use_path_style_endpoint' => true,
]);
$this->filesystem = new Filesystem(
new AwsS3V3Adapter($client, $bucket),
);
}
#[Override]
public function upload(string $path, mixed $content, string $mimeType): string
{
$config = [
'ContentType' => $mimeType,
];
if (is_resource($content)) {
$this->filesystem->writeStream($path, $content, $config);
} else {
$this->filesystem->write($path, (string) $content, $config);
}
return $path;
}
#[Override]
public function delete(string $path): void
{
try {
$this->filesystem->delete($path);
} catch (UnableToDeleteFile $e) {
$this->logger->warning('S3 delete failed, possible orphan blob: {path}', [
'path' => $path,
'error' => $e->getMessage(),
]);
}
}
#[Override]
public function readStream(string $path): mixed
{
try {
return $this->filesystem->readStream($path);
} catch (UnableToReadFile $e) {
throw new RuntimeException(sprintf('Impossible de lire le fichier : %s', $path), 0, $e);
}
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Tenant;
use function array_values;
use Doctrine\DBAL\Connection;
use Override;
use function parse_url;
use function sprintf;
use Symfony\Contracts\Service\ResetInterface;
/**
* Resolves tenants dynamically from the establishments table in the master database.
*
* Unlike InMemoryTenantRegistry (loaded from static config), this implementation
* makes newly created establishments immediately accessible via their subdomain
* without restarting the application.
*
* Results are lazy-loaded and cached in memory for the duration of the request.
* Implements ResetInterface so long-running workers invalidate the cache between messages.
*/
final class DoctrineTenantRegistry implements TenantRegistry, ResetInterface
{
/** @var array<string, TenantConfig>|null Indexed by tenant ID */
private ?array $byId = null;
/** @var array<string, TenantConfig>|null Indexed by subdomain */
private ?array $bySubdomain = null;
public function __construct(
private readonly Connection $connection,
private readonly string $masterDatabaseUrl,
) {
}
#[Override]
public function getConfig(TenantId $tenantId): TenantConfig
{
$this->ensureLoaded();
$key = (string) $tenantId;
if (!isset($this->byId[$key])) {
throw TenantNotFoundException::withId($tenantId);
}
return $this->byId[$key];
}
#[Override]
public function getBySubdomain(string $subdomain): TenantConfig
{
$this->ensureLoaded();
if (!isset($this->bySubdomain[$subdomain])) {
throw TenantNotFoundException::withSubdomain($subdomain);
}
return $this->bySubdomain[$subdomain];
}
#[Override]
public function exists(string $subdomain): bool
{
$this->ensureLoaded();
return isset($this->bySubdomain[$subdomain]);
}
#[Override]
public function getAllConfigs(): array
{
$this->ensureLoaded();
/** @var array<string, TenantConfig> $byId */
$byId = $this->byId;
return array_values($byId);
}
#[Override]
public function reset(): void
{
$this->byId = null;
$this->bySubdomain = null;
}
private function ensureLoaded(): void
{
if ($this->byId !== null) {
return;
}
$this->byId = [];
$this->bySubdomain = [];
/** @var array<array{tenant_id: string, subdomain: string, database_name: string}> $rows */
$rows = $this->connection->fetchAllAssociative(
"SELECT tenant_id, subdomain, database_name FROM establishments WHERE status = 'active'",
);
foreach ($rows as $row) {
$config = new TenantConfig(
tenantId: TenantId::fromString($row['tenant_id']),
subdomain: $row['subdomain'],
databaseUrl: $this->buildDatabaseUrl($row['database_name']),
);
$this->byId[$row['tenant_id']] = $config;
$this->bySubdomain[$row['subdomain']] = $config;
}
}
private function buildDatabaseUrl(string $databaseName): string
{
$parsed = parse_url($this->masterDatabaseUrl);
$scheme = $parsed['scheme'] ?? 'postgresql';
$user = $parsed['user'] ?? '';
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
$host = $parsed['host'] ?? 'localhost';
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
}
}

View File

@@ -17,23 +17,18 @@ final readonly class CreateEstablishmentHandler
) {
}
public function __invoke(CreateEstablishmentCommand $command): CreateEstablishmentResult
public function __invoke(CreateEstablishmentCommand $command): Establishment
{
$establishment = Establishment::creer(
name: $command->name,
subdomain: $command->subdomain,
adminEmail: $command->adminEmail,
createdBy: SuperAdminId::fromString($command->superAdminId),
createdAt: $this->clock->now(),
);
$this->establishmentRepository->save($establishment);
return new CreateEstablishmentResult(
establishmentId: (string) $establishment->id,
tenantId: (string) $establishment->tenantId,
name: $establishment->name,
subdomain: $establishment->subdomain,
databaseName: $establishment->databaseName,
);
return $establishment;
}
}

View File

@@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\SuperAdmin\Application\Command\CreateEstablishment;
final readonly class CreateEstablishmentResult
{
public function __construct(
public string $establishmentId,
public string $tenantId,
public string $name,
public string $subdomain,
public string $databaseName,
) {
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\SuperAdmin\Application\Command\ProvisionEstablishment;
/**
* Triggers async provisioning of a newly created establishment.
*
* Property names intentionally avoid "tenantId" to prevent the
* TenantDatabaseMiddleware from trying to switch to a database
* that doesn't exist yet.
*/
final readonly class ProvisionEstablishmentCommand
{
public function __construct(
public string $establishmentId,
public string $establishmentTenantId,
public string $databaseName,
public string $subdomain,
public string $adminEmail,
public string $establishmentName,
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\SuperAdmin\Application\Port;
use RuntimeException;
/**
* Creates a tenant database and runs migrations.
*/
interface TenantProvisioner
{
/**
* Creates the tenant database and applies the schema.
*
* @throws RuntimeException if provisioning fails
*/
public function provision(string $databaseName): void;
}

View File

@@ -18,6 +18,7 @@ final readonly class EtablissementCree implements DomainEvent
public TenantId $tenantId,
public string $name,
public string $subdomain,
public string $adminEmail,
private DateTimeImmutable $occurredOn,
) {
}

View File

@@ -38,6 +38,7 @@ final class Establishment extends AggregateRoot
public static function creer(
string $name,
string $subdomain,
string $adminEmail,
SuperAdminId $createdBy,
DateTimeImmutable $createdAt,
): self {
@@ -49,7 +50,7 @@ final class Establishment extends AggregateRoot
name: $name,
subdomain: $subdomain,
databaseName: sprintf('classeo_tenant_%s', str_replace('-', '', (string) $tenantId)),
status: EstablishmentStatus::ACTIF,
status: EstablishmentStatus::PROVISIONING,
createdAt: $createdAt,
createdBy: $createdBy,
);
@@ -59,12 +60,18 @@ final class Establishment extends AggregateRoot
tenantId: $establishment->tenantId,
name: $name,
subdomain: $subdomain,
adminEmail: $adminEmail,
occurredOn: $createdAt,
));
return $establishment;
}
public function activer(): void
{
$this->status = EstablishmentStatus::ACTIF;
}
public function desactiver(DateTimeImmutable $at): void
{
if ($this->status !== EstablishmentStatus::ACTIF) {

View File

@@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment;
enum EstablishmentStatus: string
{
case PROVISIONING = 'provisioning';
case ACTIF = 'active';
case INACTIF = 'inactive';
}

View File

@@ -8,10 +8,12 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentCommand;
use App\SuperAdmin\Application\Command\CreateEstablishment\CreateEstablishmentHandler;
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
use App\SuperAdmin\Infrastructure\Api\Resource\EstablishmentResource;
use App\SuperAdmin\Infrastructure\Security\SecuritySuperAdmin;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Messenger\MessageBusInterface;
/**
* @implements ProcessorInterface<EstablishmentResource, EstablishmentResource>
@@ -21,6 +23,7 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
public function __construct(
private CreateEstablishmentHandler $handler,
private Security $security,
private MessageBusInterface $commandBus,
) {
}
@@ -33,20 +36,29 @@ final readonly class CreateEstablishmentProcessor implements ProcessorInterface
/** @var SecuritySuperAdmin $user */
$user = $this->security->getUser();
$result = ($this->handler)(new CreateEstablishmentCommand(
$establishment = ($this->handler)(new CreateEstablishmentCommand(
name: $data->name,
subdomain: $data->subdomain,
adminEmail: $data->adminEmail,
superAdminId: $user->superAdminId(),
));
$this->commandBus->dispatch(new ProvisionEstablishmentCommand(
establishmentId: (string) $establishment->id,
establishmentTenantId: (string) $establishment->tenantId,
databaseName: $establishment->databaseName,
subdomain: $establishment->subdomain,
adminEmail: $data->adminEmail,
establishmentName: $establishment->name,
));
$resource = new EstablishmentResource();
$resource->id = $result->establishmentId;
$resource->tenantId = $result->tenantId;
$resource->name = $result->name;
$resource->subdomain = $result->subdomain;
$resource->databaseName = $result->databaseName;
$resource->status = 'active';
$resource->id = (string) $establishment->id;
$resource->tenantId = (string) $establishment->tenantId;
$resource->name = $establishment->name;
$resource->subdomain = $establishment->subdomain;
$resource->databaseName = $establishment->databaseName;
$resource->status = $establishment->status->value;
return $resource;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\SuperAdmin\Infrastructure\Provisioning;
use App\SuperAdmin\Application\Port\TenantProvisioner;
use Override;
/**
* Provisions a tenant by creating the database and running migrations.
*/
final readonly class DatabaseTenantProvisioner implements TenantProvisioner
{
public function __construct(
private TenantDatabaseCreator $databaseCreator,
private TenantMigrator $migrator,
) {
}
#[Override]
public function provision(string $databaseName): void
{
$this->databaseCreator->create($databaseName);
$this->migrator->migrate($databaseName);
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\SuperAdmin\Infrastructure\Provisioning;
use App\Administration\Application\Command\InviteUser\InviteUserCommand;
use App\Administration\Application\Command\InviteUser\InviteUserHandler;
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use App\Shared\Infrastructure\Tenant\TenantDatabaseSwitcher;
use App\SuperAdmin\Application\Command\ProvisionEstablishment\ProvisionEstablishmentCommand;
use App\SuperAdmin\Application\Port\TenantProvisioner;
use App\SuperAdmin\Domain\Model\Establishment\EstablishmentId;
use App\SuperAdmin\Domain\Repository\EstablishmentRepository;
use function parse_url;
use Psr\Log\LoggerInterface;
use function sprintf;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Throwable;
/**
* Handles the complete provisioning of a new establishment:
* 1. Creates the tenant database and runs migrations
* 2. Creates the first admin user (idempotent)
* 3. Activates the establishment
* 4. Dispatches invitation events (after activation so the tenant is resolvable)
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ProvisionEstablishmentHandler
{
public function __construct(
private TenantProvisioner $tenantProvisioner,
private InviteUserHandler $inviteUserHandler,
private UserRepository $userRepository,
private Clock $clock,
private TenantDatabaseSwitcher $databaseSwitcher,
private EstablishmentRepository $establishmentRepository,
private MessageBusInterface $eventBus,
private LoggerInterface $logger,
private string $masterDatabaseUrl,
) {
}
public function __invoke(ProvisionEstablishmentCommand $command): void
{
$this->logger->info('Starting establishment provisioning.', [
'establishment' => $command->establishmentId,
'subdomain' => $command->subdomain,
]);
$this->tenantProvisioner->provision($command->databaseName);
// Create admin user on the tenant database, collect events without dispatching
$pendingEvents = $this->createFirstAdminOnTenantDb($command);
// Activate establishment on master DB so the tenant becomes resolvable
$this->activateEstablishment($command->establishmentId);
// Now dispatch events — the tenant is active and resolvable by the middleware
foreach ($pendingEvents as $event) {
$this->eventBus->dispatch($event);
}
$this->logger->info('Establishment provisioning completed.', [
'establishment' => $command->establishmentId,
'subdomain' => $command->subdomain,
'adminEmail' => $command->adminEmail,
]);
}
/**
* @return DomainEvent[]
*/
private function createFirstAdminOnTenantDb(ProvisionEstablishmentCommand $command): array
{
$tenantDatabaseUrl = $this->buildTenantDatabaseUrl($command->databaseName);
$this->databaseSwitcher->useTenantDatabase($tenantDatabaseUrl);
try {
return $this->createFirstAdmin($command);
} catch (Throwable $e) {
$this->restoreDefaultDatabase();
throw $e;
} finally {
$this->restoreDefaultDatabase();
}
}
/**
* @return DomainEvent[]
*/
private function createFirstAdmin(ProvisionEstablishmentCommand $command): array
{
try {
$user = ($this->inviteUserHandler)(new InviteUserCommand(
tenantId: $command->establishmentTenantId,
schoolName: $command->establishmentName,
email: $command->adminEmail,
role: Role::ADMIN->value,
firstName: 'Administrateur',
lastName: $command->establishmentName,
));
return $user->pullDomainEvents();
} catch (EmailDejaUtiliseeException) {
$this->logger->info('Admin already exists, re-sending invitation.', [
'email' => $command->adminEmail,
]);
return $this->resendInvitation($command);
}
}
/**
* @return DomainEvent[]
*/
private function resendInvitation(ProvisionEstablishmentCommand $command): array
{
$existingUser = $this->userRepository->findByEmail(
new Email($command->adminEmail),
TenantId::fromString($command->establishmentTenantId),
);
if ($existingUser === null) {
return [];
}
$existingUser->renvoyerInvitation($this->clock->now());
$this->userRepository->save($existingUser);
return $existingUser->pullDomainEvents();
}
private function activateEstablishment(string $establishmentId): void
{
$establishment = $this->establishmentRepository->get(
EstablishmentId::fromString($establishmentId),
);
$establishment->activer();
$this->establishmentRepository->save($establishment);
}
private function restoreDefaultDatabase(): void
{
try {
$this->databaseSwitcher->useDefaultDatabase();
} catch (Throwable $e) {
$this->logger->error('Failed to restore default database connection.', [
'error' => $e->getMessage(),
]);
}
}
private function buildTenantDatabaseUrl(string $databaseName): string
{
$parsed = parse_url($this->masterDatabaseUrl);
$scheme = $parsed['scheme'] ?? 'postgresql';
$user = $parsed['user'] ?? '';
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
$host = $parsed['host'] ?? 'localhost';
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\SuperAdmin\Infrastructure\Provisioning;
use Doctrine\DBAL\Connection;
use function preg_match;
use Psr\Log\LoggerInterface;
use RuntimeException;
use function sprintf;
use function str_replace;
use Throwable;
/**
* Creates a PostgreSQL database for a new tenant.
*
* Extracted from the tenant:database:create console command
* to be usable programmatically during provisioning.
*/
final readonly class TenantDatabaseCreator
{
public function __construct(
private Connection $connection,
private LoggerInterface $logger,
private string $databaseUser = 'classeo',
) {
}
/**
* @throws RuntimeException if database name is invalid or creation fails
*/
public function create(string $databaseName): void
{
if (!preg_match('/^classeo_tenant_[a-z0-9_]+$/', $databaseName)) {
throw new RuntimeException(sprintf('Invalid tenant database name: "%s"', $databaseName));
}
try {
$exists = $this->connection->fetchOne(
'SELECT 1 FROM pg_database WHERE datname = :name',
['name' => $databaseName],
);
if ($exists !== false) {
$this->logger->info('Tenant database already exists, skipping creation.', [
'database' => $databaseName,
]);
return;
}
$this->connection->executeStatement(sprintf(
"CREATE DATABASE %s WITH OWNER = %s ENCODING = 'UTF8' LC_COLLATE = 'en_US.utf8' LC_CTYPE = 'en_US.utf8'",
$this->quoteIdentifier($databaseName),
$this->quoteIdentifier($this->databaseUser),
));
$this->logger->info('Tenant database created.', ['database' => $databaseName]);
} catch (Throwable $e) {
throw new RuntimeException(
sprintf('Failed to create tenant database "%s": %s', $databaseName, $e->getMessage()),
previous: $e,
);
}
}
private function quoteIdentifier(string $identifier): string
{
return '"' . str_replace('"', '""', $identifier) . '"';
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\SuperAdmin\Infrastructure\Provisioning;
use function getenv;
use function parse_url;
use Psr\Log\LoggerInterface;
use RuntimeException;
use function sprintf;
use Symfony\Component\Process\Process;
/**
* Runs Doctrine migrations for a tenant database.
*
* Spawns a subprocess with DATABASE_URL pointing to the tenant database,
* so Doctrine connects to the correct database before the kernel boots.
*/
final readonly class TenantMigrator
{
public function __construct(
private string $projectDir,
private string $masterDatabaseUrl,
private LoggerInterface $logger,
) {
}
/**
* @throws RuntimeException if migration fails
*/
public function migrate(string $databaseName): void
{
$databaseUrl = $this->buildDatabaseUrl($databaseName);
$process = new Process(
command: ['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction'],
cwd: $this->projectDir,
env: [
...getenv(),
'DATABASE_URL' => $databaseUrl,
],
timeout: 300,
);
$this->logger->info('Running migrations for tenant database.', ['database' => $databaseName]);
$process->run();
if (!$process->isSuccessful()) {
throw new RuntimeException(sprintf(
'Migration failed for tenant database "%s": %s',
$databaseName,
$process->getErrorOutput(),
));
}
$this->logger->info('Migrations completed for tenant database.', ['database' => $databaseName]);
}
private function buildDatabaseUrl(string $databaseName): string
{
$parsed = parse_url($this->masterDatabaseUrl);
$scheme = $parsed['scheme'] ?? 'postgresql';
$user = $parsed['user'] ?? '';
$pass = isset($parsed['pass']) ? ':' . $parsed['pass'] : '';
$host = $parsed['host'] ?? 'localhost';
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
$query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
return sprintf('%s://%s%s@%s%s/%s%s', $scheme, $user, $pass, $host, $port, $databaseName, $query);
}
}