feat: Provisionner automatiquement un nouvel établissement
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\SuperAdmin\Domain\Model\Establishment;
|
||||
|
||||
enum EstablishmentStatus: string
|
||||
{
|
||||
case PROVISIONING = 'provisioning';
|
||||
case ACTIF = 'active';
|
||||
case INACTIF = 'inactive';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) . '"';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user