feat: ajout de la gestion des jobs avec création, récupération et filtrage via l'API, incluant des entités et des mappers pour les échecs et les jobs

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-03-29 15:15:14 +01:00
parent d7088b14c2
commit d7ccc1e603
33 changed files with 1113 additions and 595 deletions

View File

@@ -28,5 +28,6 @@ api_platform:
- '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/ApiPlatform/Dto'
- '%kernel.project_dir%/src/Domain/Manga/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Reader/Infrastructure/ApiPlatform/Resource'
- '%kernel.project_dir%/src/Domain/Shared/Infrastructure/ApiPlatform/Resource'
patch_formats:
json: ['application/merge-patch+json']

View File

@@ -1,12 +1,15 @@
doctrine:
dbal:
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
use_savepoints: true
profiling_collect_backtrace: '%kernel.debug%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
@@ -21,13 +24,11 @@ doctrine:
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
# Ajout du mapping pour le domaine Scraping
Scraping:
type: attribute
Shared:
is_bundle: false
dir: '%kernel.project_dir%/src/Domain/Scraping/Infrastructure/Persistence/Entity'
prefix: 'App\Domain\Scraping\Infrastructure\Persistence\Entity'
alias: Scraping
dir: '%kernel.project_dir%/src/Domain/Shared/Infrastructure/Persistence/Entity'
prefix: 'App\Domain\Shared\Infrastructure\Persistence\Entity'
alias: Shared
when@test:
doctrine:

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250205231923 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE scraping_jobs (id VARCHAR(36) NOT NULL, chapter_number VARCHAR(255) NOT NULL, manga_id VARCHAR(255) NOT NULL, source_id VARCHAR(255) NOT NULL, pages JSON NOT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN scraping_jobs.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN scraping_jobs.completed_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP TABLE scraping_jobs');
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250210154832 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE scraping_jobs ADD cbz_path VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE scraping_jobs ADD failure_reason VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE scraping_jobs DROP cbz_path');
$this->addSql('ALTER TABLE scraping_jobs DROP failure_reason');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250328205205 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE failed_job (id VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, failure_reason TEXT NOT NULL, failed_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, context JSON NOT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN failed_job.failed_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE job (id VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, started_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, failure_reason TEXT DEFAULT NULL, attempts INT NOT NULL, max_attempts INT NOT NULL, context JSON NOT NULL, PRIMARY KEY(id))');
$this->addSql('COMMENT ON COLUMN job.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN job.started_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN job.completed_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP TABLE failed_job');
$this->addSql('DROP TABLE job');
}
}

View File

@@ -18,10 +18,11 @@ readonly class SearchMangaHandler
{
$mangaCollection = $this->mangaProvider->search($query->title);
return new MangaSearchResponse(
array_map(
fn (Manga$manga) => new MangaSearchItem(
id: $manga->getId()->getValue(),
fn (Manga $manga, int $index) => new MangaSearchItem(
id: $index,
externalId: $manga->getExternalId()->getValue(),
title: $manga->getTitle()->getValue(),
slug: $manga->getSlug()->getValue(),
@@ -34,7 +35,8 @@ readonly class SearchMangaHandler
thumbnailUrl: $manga->getImageUrls()?->getThumbnail(),
rating: $manga->getRating()
),
$mangaCollection->getItems()
$mangaCollection->getItems(),
array_keys($mangaCollection->getItems())
)
);
}

View File

@@ -15,7 +15,23 @@ use Symfony\Component\Validator\Constraints as Assert;
processor: CreateMangaProcessor::class,
openapiContext: [
'summary' => 'Create a new manga from Mangadex',
'description' => 'Creates a new manga by fetching its data from Mangadex using an external ID'
'description' => 'Creates a new manga by fetching its data from Mangadex using an external ID',
'requestBody' => [
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'required' => ['externalId'],
'properties' => [
'externalId' => [
'type' => 'string',
'description' => 'The Mangadex ID of the manga'
]
]
]
]
]
]
]
)
]

View File

@@ -34,6 +34,7 @@ readonly class SearchMangaStateProvider implements ProviderInterface
genres: $item->genres,
status: $item->status,
imageUrl: $item->imageUrl,
thumbnailUrl: $item->thumbnailUrl,
rating: $item->rating
),
$response->items

View File

@@ -85,17 +85,19 @@ readonly class MangadexProvider implements MangaProviderInterface
}
return new Manga(
new MangaId((string) Uuid::uuid4()),
new MangaTitle($title),
new MangaSlug($this->slugger->slug($title)->lower()),
$attributes['description']['fr'] ?? $attributes['description']['en'] ?? '',
$author,
$attributes['year'] ?? 0,
$genres,
$attributes['status'],
new ExternalId($result['id']),
$imageUrl,
null
id: new MangaId((string) Uuid::uuid4()),
title: new MangaTitle($title),
slug: new MangaSlug($this->slugger->slug($title)->lower()),
description: $attributes['description']['fr'] ?? $attributes['description']['en'] ?? '',
author: $author,
publicationYear: $attributes['year'] ?? 0,
genres: $genres,
status: $attributes['status'],
externalId: new ExternalId($result['id']),
imageUrl: $imageUrl,
rating: null,
imageUrls: null,
createdAt: new \DateTimeImmutable(),
);
} catch (\Exception $e) {
return null;

View File

@@ -17,8 +17,10 @@ use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\ValueObject\CbzGenerationRequest;
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Messenger\MessageBusInterface;
use Doctrine\ORM\EntityManagerInterface;
readonly class ScrapeChapterHandler
{
@@ -26,51 +28,54 @@ readonly class ScrapeChapterHandler
private ScraperInterface $scraper,
private ImageDownloaderInterface $imageDownloader,
private CbzGeneratorInterface $cbzGenerator,
private ScrapingJobRepositoryInterface $scrapingJobRepository,
private JobRepositoryInterface $jobRepository,
private ChapterRepositoryInterface $chapterRepository,
private MangaRepositoryInterface $mangaRepository,
private SourceRepositoryInterface $sourceRepository,
private MessageBusInterface $eventBus
private MessageBusInterface $eventBus,
private EntityManagerInterface $entityManager
) {
}
public function handle(ScrapeChapter $command): void
{
$job = null;
try {
// 1. Création du job
// 1. Création du job dans sa propre transaction
$job = new ScrapingJob(
Uuid::uuid4()->toString(),
$command->mangaId,
$command->chapterNumber,
$command->sourceId
);
$this->scrapingJobRepository->save($job);
$job->start();
$this->jobRepository->save($job);
// 2. Préparation des données
// 2. Nouvelle transaction pour le reste des opérations
$this->entityManager->beginTransaction();
try {
// Préparation des données
$manga = $this->mangaRepository->getById($command->mangaId);
$chapter = $this->chapterRepository->getByMangaIdAndChapterNumber($command->mangaId, $command->chapterNumber);
$source = $this->sourceRepository->getById($command->sourceId);
$this->eventBus->dispatch(new ChapterScrapingStarted($job->getId()));
throw new \Exception('test');
// 3. Scraping des URLs
$scrapingRequest = new ScrapingRequest(
'html',
$source->buildChapterUrl($manga->getSlug(), $command->chapterNumber),
$source->getScrappingParameters(),
$job->getId()
$source->getScrappingParameters()
);
$scrapingResult = $this->scraper->scrape($scrapingRequest);
$job->totalPages = $scrapingResult->getTotalPages();
$this->scrapingJobRepository->save($job);
// 4. Téléchargement des images
$tempDir = new TempDirectory();
$downloadResults = $this->imageDownloader->downloadBatch(
$scrapingResult->getImageUrls(),
$tempDir,
$job->getId()
$job->id
);
// 5. Génération du CBZ
@@ -86,24 +91,28 @@ readonly class ScrapeChapterHandler
$cbzPath = $this->cbzGenerator->generate($cbzRequest);
// 6. Mise à jour et sauvegarde
$job->complete();
$job->cbzPath = $cbzPath;
$this->scrapingJobRepository->save($job);
$chapter->cbzPath = $cbzPath->getPath();
$this->chapterRepository->save($chapter);
$this->eventBus->dispatch(new ChapterScraped($job->getId()));
$job->complete();
$this->jobRepository->save($job);
$this->entityManager->commit();
$this->eventBus->dispatch(new ChapterScraped($job->id));
// 7. Nettoyage
$tempDir->cleanup();
} catch (\Exception $e) {
$this->entityManager->rollback();
throw $e;
}
} catch (\Exception $e) {
if (isset($job)) {
$job->fail($e->getMessage());
$this->scrapingJobRepository->save($job);
$this->jobRepository->save($job);
}
$this->eventBus->dispatch(new ChapterScrapingFailed($command->mangaId, $command->chapterNumber, $e->getMessage()));
throw $e;
}
}
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Domain\Scraping\Domain\Contract\Repository;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
interface ScrapingJobRepositoryInterface
{
public function save(ScrapingJob $job): void;
public function findById(string $id): ?ScrapingJob;
public function findByChapterId(string $chapterId): ?ScrapingJob;
}

View File

@@ -2,73 +2,22 @@
namespace App\Domain\Scraping\Domain\Model;
use App\Domain\Scraping\Domain\Model\ValueObject\CbzPath;
use App\Domain\Scraping\Domain\Model\ValueObject\ImageUrl;
use App\Domain\Scraping\Domain\Model\ValueObject\PageNumber;
use App\Domain\Shared\Domain\Model\Job;
class ScrapingJob
class ScrapingJob extends Job
{
public array $pages = [];
public int $totalPages = 0;
public ScrapingStatus $status;
public ?CbzPath $cbzPath = null;
public string $failureReason = '';
public \DateTimeImmutable $createdAt;
public ?\DateTimeImmutable $completedAt = null;
public function __construct(
private readonly string $id,
private readonly string $mangaId,
private readonly float $chapterNumber,
private readonly string $sourceId
string $id,
string $mangaId,
float $chapterNumber,
string $sourceId
) {
$this->status = ScrapingStatus::PENDING;
$this->createdAt = new \DateTimeImmutable();
}
public function addPage(PageNumber $pageNumber, ImageUrl $imageUrl): void
{
$this->pages[$pageNumber->getValue()] = $imageUrl->getValue();
if ($this->status === ScrapingStatus::PENDING) {
$this->status = ScrapingStatus::IN_PROGRESS;
}
}
public function complete(): void
{
$this->status = ScrapingStatus::COMPLETED;
$this->completedAt = new \DateTimeImmutable();
}
public function fail(string $exceptionMessage): void
{
$this->failureReason = $exceptionMessage;
$this->status = ScrapingStatus::FAILED;
$this->completedAt = new \DateTimeImmutable();
}
public function getId(): string
{
return $this->id;
}
public function getChapterNumber(): float
{
return $this->chapterNumber;
}
public function getMangaId(): string
{
return $this->mangaId;
}
public function getSourceId(): string
{
return $this->sourceId;
}
public function setStatus(ScrapingStatus $status): void
{
$this->status = $status;
parent::__construct($id, 'scraping_job');
$this->maxAttempts = 1;
$this->context = [
'mangaId' => $mangaId,
'chapterNumber' => $chapterNumber,
'sourceId' => $sourceId
];
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Domain\Scraping\Domain\Model;
enum ScrapingStatus: string
{
case PENDING = 'pending';
case IN_PROGRESS = 'in_progress';
case COMPLETED = 'completed';
case FAILED = 'failed';
}

View File

@@ -8,7 +8,6 @@ readonly class ScrapingRequest
private string $sourceType,
private string $chapterUrl,
private array $scrapingParameters,
private string $jobId
) {
}
@@ -26,9 +25,4 @@ readonly class ScrapingRequest
{
return $this->scrapingParameters;
}
public function getJobId(): string
{
return $this->jobId;
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\Dto;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Domain\Scraping\Infrastructure\ApiPlatform\State\Provider\ScrapingStatusStateProvider;
use ApiPlatform\Metadata\Link;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
#[ApiResource(
shortName: 'Scraping',
operations: [
new Get(
uriTemplate: '/scraping/jobs/{jobId}/status',
provider: ScrapingStatusStateProvider::class,
uriVariables: [
'jobId' => new Link(
fromProperty: 'jobId',
toProperty: 'id',
fromClass: ScrapingStatusResponse::class,
toClass: ScrapingJob::class
)
]
),
],
)]
readonly class ScrapingStatusResponse
{
public function __construct(
#[ApiProperty(identifier: true)]
public string $jobId,
#[ApiProperty]
public string $status,
#[ApiProperty]
public ?float $progress = null,
#[ApiProperty]
public ?string $error = null
) {
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface;
use App\Domain\Scraping\Infrastructure\ApiPlatform\Dto\ScrapingStatusResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class ScrapingStatusStateProvider implements ProviderInterface
{
public function __construct(
private ScrapingJobRepositoryInterface $scrapingJobRepository
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ScrapingStatusResponse
{
$job = $this->scrapingJobRepository->findById($uriVariables['jobId']);
if (!$job) {
throw new NotFoundHttpException('Job de scraping non trouvé');
}
$progress = 0;
if ($job->totalPages > 0) {
$progress = (count($job->pages) / $job->totalPages) * 100;
}
return new ScrapingStatusResponse(
jobId: $job->getId(),
status: $job->status->value,
progress: $progress
);
}
}

View File

@@ -1,76 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\Persistence;
use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\ScrapingStatus;
use App\Domain\Scraping\Infrastructure\Persistence\Entity\ScrapingJobEntity;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineScrapingJobRepository implements ScrapingJobRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {
}
public function save(ScrapingJob $job): void
{
/** @var ScrapingJobEntity $existingEntity */
$existingEntity = $this->entityManager->getRepository(ScrapingJobEntity::class)->find($job->getId());
if ($existingEntity) {
$existingEntity->setStatus($job->status->value);
$existingEntity->setPages($job->pages);
$existingEntity->setCompletedAt($job->completedAt);
$existingEntity->setCbzPath($job->cbzPath?->getPath());
$existingEntity->setFailureReason($job->failureReason);
} else {
$entity = ScrapingJobEntity::fromDomain($job);
$this->entityManager->persist($entity);
}
$this->entityManager->flush();
}
public function findById(string $id): ?ScrapingJob
{
$entity = $this->entityManager->getRepository(ScrapingJobEntity::class)
->find($id);
return $entity?->toDomain();
}
public function findByChapterId(string $chapterId): ?ScrapingJob
{
$entity = $this->entityManager->getRepository(ScrapingJobEntity::class)
->findOneBy(['chapterId' => $chapterId]);
return $entity?->toDomain();
}
public function findPendingJobs(): array
{
$entities = $this->entityManager->getRepository(ScrapingJobEntity::class)
->createQueryBuilder('sj')
->where('sj.status = :status')
->setParameter('status', ScrapingStatus::PENDING->value)
->getQuery()
->getResult();
return array_map(fn (ScrapingJobEntity $entity) => $entity->toDomain(), $entities);
}
public function findInProgressJobs(): array
{
$entities = $this->entityManager->getRepository(ScrapingJobEntity::class)
->createQueryBuilder('sj')
->where('sj.status = :status')
->setParameter('status', ScrapingStatus::IN_PROGRESS->value)
->getQuery()
->getResult();
return array_map(fn (ScrapingJobEntity $entity) => $entity->toDomain(), $entities);
}
}

View File

@@ -1,103 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\Persistence\Entity;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\ScrapingStatus;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'scraping_jobs')]
class ScrapingJobEntity
{
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
private string $id;
#[ORM\Column(type: 'string')]
private string $chapterNumber;
#[ORM\Column(type: 'string')]
private string $mangaId;
#[ORM\Column(type: 'string')]
private string $sourceId;
#[ORM\Column(type: 'json')]
private array $pages = [];
#[ORM\Column(type: 'string')]
private string $status;
#[ORM\Column(type: 'string', nullable: true)]
private ?string $cbzPath = null;
#[ORM\Column(type: 'string', nullable: true)]
private ?string $failureReason = '';
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
public static function fromDomain(ScrapingJob $job): self
{
$entity = new self();
$entity->id = $job->getId();
$entity->chapterNumber = $job->getChapterNumber();
$entity->mangaId = $job->getMangaId();
$entity->sourceId = $job->getSourceId();
$entity->pages = $job->pages;
$entity->status = $job->status->value;
$entity->createdAt = $job->createdAt;
$entity->completedAt = $job->completedAt;
$entity->cbzPath = $job->cbzPath?->getPath();
$entity->failureReason = $job->failureReason;
return $entity;
}
public function toDomain(): ScrapingJob
{
$job = new ScrapingJob(
id: $this->id,
mangaId: $this->mangaId,
chapterNumber: $this->chapterNumber,
sourceId: $this->sourceId
);
$job->status = ScrapingStatus::from($this->status);
$job->pages = $this->pages;
$job->createdAt = $this->createdAt;
$job->completedAt = $this->completedAt;
$job->cbzPath = $this->cbzPath;
$job->failureReason = $this->failureReason;
return $job;
}
public function setStatus(string $status): void
{
$this->status = $status;
}
public function setPages(array $pages): void
{
$this->pages = $pages;
}
public function setCompletedAt(?\DateTimeImmutable $completedAt): void
{
$this->completedAt = $completedAt;
}
public function setCbzPath(?string $cbzPath = null): void
{
$this->cbzPath = $cbzPath;
}
public function setFailureReason(string $failureReason): void
{
$this->failureReason = $failureReason;
}
}

View File

@@ -1,76 +0,0 @@
<?php
namespace App\Domain\Scraping\Infrastructure\Service\Scraper;
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface;
use App\Domain\Scraping\Domain\Event\PageScrapingProgressed;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\ScrapingProgress;
use App\Domain\Scraping\Domain\Model\Source;
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingResult;
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
use Symfony\Component\Messenger\MessageBusInterface;
use Ramsey\Uuid\Uuid;
abstract class AbstractScraper implements ScraperInterface
{
public function __construct(
protected ImageDownloaderInterface $imageDownloader,
protected MessageBusInterface $eventBus
) {
}
abstract public function scrape(ScrapingRequest $request): ScrapingResult;
abstract protected function scrapePages(ScrapingJob $job, Source $source): array;
protected function cleanupTempDirectory(string $tempDir): void
{
if (is_dir($tempDir)) {
$files = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($tempDir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
if ($file->isDir()) {
rmdir($file->getRealPath());
} else {
unlink($file->getRealPath());
}
}
rmdir($tempDir);
}
}
protected function dispatchProgressEvent(ScrapingJob $job, int $currentPage, int $totalPages): void
{
$progress = new ScrapingProgress($currentPage, $totalPages);
$this->eventBus->dispatch(new PageScrapingProgressed($job->getId(), $progress));
}
protected function downloadImage(string $imageUrl, string $destination): void
{
$this->imageDownloader->download($imageUrl, $destination);
}
protected function createTempDirectory(): TempDirectory
{
return new TempDirectory(sys_get_temp_dir() . '/' . uniqid('manga_scraper_'));
}
protected function cleanupTempFiles(TempDirectory $tempDirectory): void
{
$files = glob($tempDirectory->getPath() . '/*');
foreach ($files as $file) {
if (is_file($file)) {
unlink($file);
}
}
rmdir($tempDirectory->getPath());
}
abstract public function supports(string $sourceType): bool;
}

View File

@@ -2,16 +2,7 @@
namespace App\Domain\Scraping\Infrastructure\Service\Scraper;
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\ScrapingJobRepositoryInterface;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Scraping\Domain\Model\Source;
use App\Domain\Scraping\Domain\Model\ValueObject\ImageUrl;
use App\Domain\Scraping\Domain\Model\ValueObject\PageNumber;
use App\Domain\Scraping\Domain\Contract\Repository\SourceRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Service\CbzGeneratorInterface;
use App\Domain\Scraping\Domain\Contract\Service\ImageDownloaderInterface;
use App\Domain\Scraping\Domain\Model\ValueObject\ChapterUrl;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -19,7 +10,6 @@ use App\Domain\Scraping\Domain\Contract\Service\ScraperInterface;
use App\Domain\Scraping\Domain\Event\PageScrapingProgressed;
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingRequest;
use App\Domain\Scraping\Domain\Model\ValueObject\ScrapingResult;
use App\Domain\Scraping\Domain\Model\ValueObject\TempDirectory;
use App\Domain\Scraping\Domain\Model\ScrapingProgress;
class HtmlScraper implements ScraperInterface
@@ -90,8 +80,6 @@ class HtmlScraper implements ScraperInterface
$nextLink = $crawler->filter($params['nextPageSelector']);
$currentUrl = $nextLink->count() > 0 ? $nextLink->attr('href') : null;
$this->dispatchProgressEvent($request->getJobId(), count($pages), count($pages));
}
return $pages;
@@ -117,12 +105,4 @@ class HtmlScraper implements ScraperInterface
{
return preg_replace('/[\x00-\x1F\x7F]/', '', trim($url));
}
private function dispatchProgressEvent(string $jobId, int $currentPage, int $totalPages): void
{
$this->eventBus->dispatch(new PageScrapingProgressed(
$jobId,
new ScrapingProgress($currentPage, $totalPages)
));
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\Query;
use App\Domain\Shared\Domain\Contract\QueryInterface;
use App\Domain\Shared\Domain\Model\JobStatus;
readonly class ListJobsQuery implements QueryInterface
{
public function __construct(
public ?JobStatus $status = null,
public ?string $type = null,
public ?\DateTimeImmutable $createdAfter = null,
public ?\DateTimeImmutable $createdBefore = null,
public ?int $page = 1,
public ?int $limit = 20,
public ?string $sortBy = 'createdAt',
public ?string $sortOrder = 'DESC'
) {
if ($this->page < 1) {
throw new \InvalidArgumentException('Page must be greater than 0');
}
if ($this->limit < 1) {
throw new \InvalidArgumentException('Limit must be greater than 0');
}
}
public function getOffset(): int
{
return ($this->page - 1) * $this->limit;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\QueryHandler;
use App\Domain\Shared\Application\Query\ListJobsQuery;
use App\Domain\Shared\Application\Response\JobListResponse;
use App\Domain\Shared\Domain\Contract\QueryHandlerInterface;
use App\Domain\Shared\Domain\Contract\QueryInterface;
use App\Domain\Shared\Domain\Contract\ResponseInterface;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
readonly class ListJobsQueryHandler implements QueryHandlerInterface
{
public function __construct(
private JobRepositoryInterface $jobRepository
) {}
public function handle(QueryInterface $query): ResponseInterface
{
if (!$query instanceof ListJobsQuery) {
throw new \InvalidArgumentException(sprintf(
'Query must be instance of %s, %s given',
ListJobsQuery::class,
get_class($query)
));
}
$criteria = [
'status' => $query->status,
'type' => $query->type,
'createdAfter' => $query->createdAfter,
'createdBefore' => $query->createdBefore,
'sortBy' => $query->sortBy,
'sortOrder' => $query->sortOrder,
'offset' => $query->getOffset(),
'limit' => $query->limit
];
$jobs = $this->jobRepository->findByCriteria($criteria);
$total = $this->jobRepository->countByCriteria($criteria);
return JobListResponse::fromJobs(
jobs: $jobs,
total: $total,
page: $query->page,
limit: $query->limit
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\Response;
use App\Domain\Shared\Domain\Contract\ResponseInterface;
use App\Domain\Shared\Domain\Model\Job;
readonly class JobListResponse implements ResponseInterface
{
/**
* @param Job[] $items
*/
public function __construct(
public array $items,
public int $total,
public int $page,
public int $limit,
public int $pages
) {}
public static function fromJobs(array $jobs, int $total, int $page, int $limit): self
{
return new self(
items: $jobs,
total: $total,
page: $page,
limit: $limit,
pages: (int) ceil($total / $limit)
);
}
}

View File

@@ -11,6 +11,5 @@ interface FailedJobRepositoryInterface
public function delete(string $id): void;
public function findAll(): array;
public function findByJobType(string $jobType): array;
public function findByJobId(string $jobId): array;
public function findRetryableJobs(): array;
}

View File

@@ -14,4 +14,29 @@ interface JobRepositoryInterface
public function findPendingJobs(): array;
public function findInProgressJobs(): array;
public function findFailedJobs(): array;
/**
* @param array{
* status?: ?JobStatus,
* type?: ?string,
* createdAfter?: ?\DateTimeImmutable,
* createdBefore?: ?\DateTimeImmutable,
* sortBy?: string,
* sortOrder?: string,
* offset?: int,
* limit?: int
* } $criteria
* @return Job[]
*/
public function findByCriteria(array $criteria): array;
/**
* @param array{
* status?: ?JobStatus,
* type?: ?string,
* createdAfter?: ?\DateTimeImmutable,
* createdBefore?: ?\DateTimeImmutable
* } $criteria
*/
public function countByCriteria(array $criteria): int;
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider\GetJobListStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Job',
operations: [
new GetCollection(
uriTemplate: '/jobs',
provider: GetJobListStateProvider::class,
output: GetJobListResource::class,
description: 'Liste des jobs',
openapiContext: [
'parameters' => [
[
'name' => 'status',
'in' => 'query',
'description' => 'Filtrer par status',
'required' => false,
'schema' => [
'type' => 'string',
'enum' => ['pending', 'in_progress', 'completed', 'failed'],
'example' => 'pending'
]
],
[
'name' => 'type',
'in' => 'query',
'description' => 'Filtrer par type de job (ex: scraping_job)',
'required' => false,
'schema' => ['type' => 'string']
],
[
'name' => 'createdAfter',
'in' => 'query',
'description' => 'Date de création minimum (format ISO8601)',
'required' => false,
'schema' => ['type' => 'string', 'format' => 'date-time']
],
[
'name' => 'createdBefore',
'in' => 'query',
'description' => 'Date de création maximum (format ISO8601)',
'required' => false,
'schema' => ['type' => 'string', 'format' => 'date-time']
],
[
'name' => 'page',
'in' => 'query',
'description' => 'Numéro de la page',
'required' => false,
'schema' => ['type' => 'integer', 'default' => 1, 'minimum' => 1]
],
[
'name' => 'limit',
'in' => 'query',
'description' => 'Nombre d\'éléments par page',
'required' => false,
'schema' => ['type' => 'integer', 'default' => 20, 'minimum' => 1]
],
[
'name' => 'sortBy',
'in' => 'query',
'description' => 'Champ de tri',
'required' => false,
'schema' => [
'type' => 'string',
'enum' => ['createdAt', 'type', 'status'],
'default' => 'createdAt'
]
],
[
'name' => 'sortOrder',
'in' => 'query',
'description' => 'Ordre de tri',
'required' => false,
'schema' => [
'type' => 'string',
'enum' => ['ASC', 'DESC'],
'default' => 'DESC'
]
]
]
]
)
]
)]
class GetJobListResource
{
public function __construct(
#[ApiProperty(
identifier: true,
description: 'Identifiant unique du job'
)]
public readonly string $id,
#[ApiProperty(description: 'Type du job (ex: scraping_job)')]
#[Assert\NotBlank]
public readonly string $type,
#[ApiProperty(
description: 'Status du job',
openapiContext: ['enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled']]
)]
#[Assert\NotBlank]
public readonly string $status,
#[ApiProperty(description: 'Date de création du job')]
#[Assert\NotNull]
public readonly \DateTimeImmutable $createdAt,
#[ApiProperty(description: 'Date de début d\'exécution du job')]
public readonly ?\DateTimeImmutable $startedAt = null,
#[ApiProperty(description: 'Date de fin d\'exécution du job')]
public readonly ?\DateTimeImmutable $completedAt = null,
#[ApiProperty(description: 'Raison de l\'échec si le job a échoué')]
public readonly ?string $failureReason = null,
#[ApiProperty(description: 'Nombre de tentatives effectuées')]
#[Assert\GreaterThanOrEqual(0)]
public readonly int $attempts = 0,
#[ApiProperty(description: 'Nombre maximum de tentatives autorisées')]
#[Assert\GreaterThan(0)]
public readonly int $maxAttempts = 3,
#[ApiProperty(description: 'Données contextuelles du job')]
public readonly array $context = []
) {}
public static function fromJob(\App\Domain\Shared\Domain\Model\Job $job): self
{
return new self(
id: $job->id,
type: $job->type,
status: $job->status->value,
createdAt: $job->createdAt,
startedAt: $job->startedAt,
completedAt: $job->completedAt,
failureReason: $job->failureReason,
attempts: $job->attempts,
maxAttempts: $job->maxAttempts,
context: $job->context
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Domain\Shared\Application\Query\ListJobsQuery;
use App\Domain\Shared\Application\QueryHandler\ListJobsQueryHandler;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\ApiPlatform\Resource\GetJobListResource;
readonly class GetJobListStateProvider implements ProviderInterface
{
public function __construct(
private ListJobsQueryHandler $handler
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$filters = $context['filters'] ?? [];
$query = new ListJobsQuery(
status: isset($filters['status']) ? JobStatus::from($filters['status']) : null,
type: $filters['type'] ?? null,
createdAfter: isset($filters['createdAfter']) ? new \DateTimeImmutable($filters['createdAfter']) : null,
createdBefore: isset($filters['createdBefore']) ? new \DateTimeImmutable($filters['createdBefore']) : null,
page: (int) ($filters['page'] ?? 1),
limit: (int) ($filters['limit'] ?? 20),
sortBy: $filters['sortBy'] ?? 'createdAt',
sortOrder: $filters['sortOrder'] ?? 'DESC'
);
$response = $this->handler->handle($query);
return [
'items' => array_map(
fn($job) => GetJobListResource::fromJob($job),
$response->items
),
'total' => $response->total,
'page' => $response->page,
'limit' => $response->limit,
'pages' => $response->pages
];
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'failed_job')]
class FailedJobEntity
{
#[ORM\Id]
#[ORM\Column]
private string $id;
#[ORM\Column(type: 'string')]
private string $type;
#[ORM\Column(type: 'text')]
private string $failureReason;
#[ORM\Column]
private \DateTimeImmutable $failedAt;
#[ORM\Column(type: 'json')]
private array $context = [];
public function getId(): string
{
return $this->id;
}
public function setId(string $id): self
{
$this->id = $id;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getFailureReason(): string
{
return $this->failureReason;
}
public function setFailureReason(string $failureReason): self
{
$this->failureReason = $failureReason;
return $this;
}
public function getFailedAt(): \DateTimeImmutable
{
return $this->failedAt;
}
public function setFailedAt(\DateTimeImmutable $failedAt): self
{
$this->failedAt = $failedAt;
return $this;
}
public function getContext(): array
{
return $this->context;
}
public function setContext(array $context): self
{
$this->context = $context;
return $this;
}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'job')]
class JobEntity
{
#[ORM\Id]
#[ORM\Column]
private string $id;
#[ORM\Column(type: 'string')]
private string $type;
#[ORM\Column(type: 'string')]
private string $status;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $startedAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $failureReason = null;
#[ORM\Column(type: 'integer')]
private int $attempts = 0;
#[ORM\Column(type: 'integer')]
private int $maxAttempts = 3;
#[ORM\Column(type: 'json')]
private array $context = [];
public function getId(): string
{
return $this->id;
}
public function setId(string $id): self
{
$this->id = $id;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): self
{
$this->type = $type;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getStartedAt(): ?\DateTimeImmutable
{
return $this->startedAt;
}
public function setStartedAt(?\DateTimeImmutable $startedAt): self
{
$this->startedAt = $startedAt;
return $this;
}
public function getCompletedAt(): ?\DateTimeImmutable
{
return $this->completedAt;
}
public function setCompletedAt(?\DateTimeImmutable $completedAt): self
{
$this->completedAt = $completedAt;
return $this;
}
public function getFailureReason(): ?string
{
return $this->failureReason;
}
public function setFailureReason(?string $failureReason): self
{
$this->failureReason = $failureReason;
return $this;
}
public function getAttempts(): int
{
return $this->attempts;
}
public function setAttempts(int $attempts): self
{
$this->attempts = $attempts;
return $this;
}
public function getMaxAttempts(): int
{
return $this->maxAttempts;
}
public function setMaxAttempts(int $maxAttempts): self
{
$this->maxAttempts = $maxAttempts;
return $this;
}
public function getContext(): array
{
return $this->context;
}
public function setContext(array $context): self
{
$this->context = $context;
return $this;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Mapper;
use App\Domain\Shared\Domain\Model\FailedJob;
use App\Domain\Shared\Infrastructure\Persistence\Entity\FailedJobEntity;
readonly class FailedJobMapper
{
public function toEntity(FailedJob $job): FailedJobEntity
{
$entity = new FailedJobEntity();
$entity->setId($job->id)
->setType($job->jobType)
->setFailureReason($job->failureReason)
->setFailedAt($job->failedAt)
->setContext($job->context);
return $entity;
}
public function toDomain(FailedJobEntity $entity): FailedJob
{
return new FailedJob(
id: $entity->getId(),
jobId: $entity->getId(), // On utilise le même ID car on n'a pas de référence au job original
jobType: $entity->getType(),
failureReason: $entity->getFailureReason(),
context: $entity->getContext(),
failedAt: $entity->getFailedAt(),
attempt: 1 // Par défaut car on n'a pas cette info dans l'entité
);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Mapper;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Shared\Domain\Model\Job;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\Persistence\Entity\JobEntity;
readonly class JobMapper
{
public function toEntity(Job $job): JobEntity
{
$entity = new JobEntity();
$entity->setId($job->id)
->setType($job->type)
->setStatus($job->status->value)
->setCreatedAt($job->createdAt)
->setStartedAt($job->startedAt)
->setCompletedAt($job->completedAt)
->setFailureReason($job->failureReason)
->setAttempts($job->attempts)
->setMaxAttempts($job->maxAttempts)
->setContext($job->context);
return $entity;
}
public function toDomain(JobEntity $entity): Job
{
$job = match($entity->getType()) {
'scraping_job' => new ScrapingJob(
$entity->getId(),
$entity->getContext()['mangaId'],
$entity->getContext()['chapterNumber'],
$entity->getContext()['sourceId']
),
default => throw new \RuntimeException(sprintf('Unknown job type: %s', $entity->getType()))
};
$job->status = JobStatus::from($entity->getStatus());
$job->createdAt = $entity->getCreatedAt();
$job->startedAt = $entity->getStartedAt();
$job->completedAt = $entity->getCompletedAt();
$job->failureReason = $entity->getFailureReason();
$job->attempts = $entity->getAttempts();
$job->maxAttempts = $entity->getMaxAttempts();
$job->context = $entity->getContext();
return $job;
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Repository;
use App\Domain\Shared\Domain\Contract\FailedJobRepositoryInterface;
use App\Domain\Shared\Domain\Model\FailedJob;
use App\Domain\Shared\Domain\Model\Job;
use App\Domain\Shared\Infrastructure\Persistence\Entity\FailedJobEntity;
use App\Domain\Shared\Infrastructure\Persistence\Mapper\FailedJobMapper;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineFailedJobRepository implements FailedJobRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private FailedJobMapper $mapper
) {
}
public function save(FailedJob $job): void
{
$entity = $this->mapper->toEntity($job);
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
public function get(string $id): FailedJob
{
$job = $this->findById($id);
if (null === $job) {
throw new \RuntimeException(sprintf('Failed job with id %s not found', $id));
}
return $job;
}
public function delete(string $id): void
{
$entity = $this->entityManager->find(FailedJobEntity::class, $id);
if ($entity) {
$this->entityManager->remove($entity);
$this->entityManager->flush();
}
}
public function findAll(): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(FailedJobEntity::class, 'j')
->getQuery()
->getResult();
return array_map(fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
public function findById(string $id): ?FailedJob
{
$entity = $this->entityManager->find(FailedJobEntity::class, $id);
if (null === $entity) {
return null;
}
return $this->mapper->toDomain($entity);
}
public function findByJobType(string $type): array
{
return $this->findByType($type);
}
public function findRetryableJobs(): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(FailedJobEntity::class, 'j')
->getQuery()
->getResult();
return array_map(
fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity),
array_filter(
$entities,
fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity)->attempt < 3
)
);
}
private function findByType(string $type): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(FailedJobEntity::class, 'j')
->where('j.type = :type')
->setParameter('type', $type)
->getQuery()
->getResult();
return array_map(fn(FailedJobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Domain\Shared\Infrastructure\Persistence\Repository;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
use App\Domain\Shared\Domain\Model\Job;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\Persistence\Entity\JobEntity;
use App\Domain\Shared\Infrastructure\Persistence\Mapper\JobMapper;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineJobRepository implements JobRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private JobMapper $mapper
) {
}
public function save(Job $job): void
{
dump('save', $job);
/** @var JobEntity|null $existingJobEntity */
$existingJobEntity = $this->entityManager->find(JobEntity::class, $job->id);
if ($existingJobEntity) {
dump('existingJobEntity', $existingJobEntity);
$existingJobEntity->setStatus($job->status->value);
$existingJobEntity->setStartedAt($job->startedAt);
$existingJobEntity->setCompletedAt($job->completedAt);
$existingJobEntity->setFailureReason($job->failureReason);
$existingJobEntity->setAttempts($job->attempts);
$existingJobEntity->setContext($job->context);
$this->entityManager->persist($existingJobEntity);
dump('updated', $existingJobEntity);
} else {
$entity = $this->mapper->toEntity($job);
$this->entityManager->persist($entity);
dump('created', $entity);
}
$this->entityManager->flush();
dump('flushed');
}
public function get(string $id): Job
{
$job = $this->findById($id);
if (null === $job) {
throw JobNotFoundException::withId($id);
}
return $job;
}
public function findById(string $id): ?Job
{
$entity = $this->entityManager->find(JobEntity::class, $id);
if (null === $entity) {
return null;
}
return $this->mapper->toDomain($entity);
}
public function findByStatus(JobStatus $status): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(JobEntity::class, 'j')
->where('j.status = :status')
->setParameter('status', $status->value)
->getQuery()
->getResult();
return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
public function findPendingJobs(): array
{
return $this->findByStatus(JobStatus::PENDING);
}
public function findInProgressJobs(): array
{
return $this->findByStatus(JobStatus::IN_PROGRESS);
}
public function findFailedJobs(): array
{
return $this->findByStatus(JobStatus::FAILED);
}
public function findByType(string $type): array
{
$entities = $this->entityManager->createQueryBuilder()
->select('j')
->from(JobEntity::class, 'j')
->where('j.type = :type')
->setParameter('type', $type)
->getQuery()
->getResult();
return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
public function findByCriteria(array $criteria): array
{
$qb = $this->entityManager->createQueryBuilder()
->select('j')
->from(JobEntity::class, 'j');
if (isset($criteria['status'])) {
$qb->andWhere('j.status = :status')
->setParameter('status', $criteria['status']->value);
}
if (isset($criteria['type'])) {
$qb->andWhere('j.type = :type')
->setParameter('type', $criteria['type']);
}
if (isset($criteria['createdAfter'])) {
$qb->andWhere('j.createdAt >= :createdAfter')
->setParameter('createdAfter', $criteria['createdAfter']);
}
if (isset($criteria['createdBefore'])) {
$qb->andWhere('j.createdAt <= :createdBefore')
->setParameter('createdBefore', $criteria['createdBefore']);
}
if (isset($criteria['sortBy'])) {
$qb->orderBy('j.' . $criteria['sortBy'], $criteria['sortOrder'] ?? 'ASC');
}
if (isset($criteria['offset'])) {
$qb->setFirstResult($criteria['offset']);
}
if (isset($criteria['limit'])) {
$qb->setMaxResults($criteria['limit']);
}
$entities = $qb->getQuery()->getResult();
return array_map(fn(JobEntity $entity) => $this->mapper->toDomain($entity), $entities);
}
public function countByCriteria(array $criteria): int
{
$qb = $this->entityManager->createQueryBuilder()
->select('COUNT(j.id)')
->from(JobEntity::class, 'j');
if (isset($criteria['status'])) {
$qb->andWhere('j.status = :status')
->setParameter('status', $criteria['status']->value);
}
if (isset($criteria['type'])) {
$qb->andWhere('j.type = :type')
->setParameter('type', $criteria['type']);
}
if (isset($criteria['createdAfter'])) {
$qb->andWhere('j.createdAt >= :createdAfter')
->setParameter('createdAfter', $criteria['createdAfter']);
}
if (isset($criteria['createdBefore'])) {
$qb->andWhere('j.createdAt <= :createdBefore')
->setParameter('createdBefore', $criteria['createdBefore']);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
}