feat: activity page

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2026-03-11 20:54:55 +01:00
parent f418b36167
commit 19395b4869
21 changed files with 625 additions and 35 deletions

View File

@@ -23,8 +23,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
url += `&status=${status.join(',')}`;
}
console.log('Fetching jobs from URL:', url);
const response = await fetch(url);
if (!response.ok) {
@@ -32,7 +30,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
}
const data = await response.json();
console.log('API Response:', data);
// Gérer différents formats de réponse API
let jobs, total, currentPage, limit_returned, hasNext, hasPrev;
@@ -63,15 +60,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
hasPrev = !!data.hasPreviousPage;
}
console.log('Processed data:', {
jobs: jobs.length,
total,
currentPage,
limit_returned,
hasNext,
hasPrev
});
return new JobCollection(
jobs,
total,
@@ -81,7 +69,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
hasPrev
);
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
@@ -102,7 +89,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
const data = await response.json();
return Job.create(data);
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
@@ -124,7 +110,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
@@ -158,7 +143,6 @@ export class ApiJobRepository extends JobRepositoryInterface {
const data = await response.json();
return data.deleted || 0;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}

View File

@@ -11,16 +11,6 @@
</div>
<div v-else class="container mx-auto p-2">
<!-- Debug pagination - À supprimer plus tard -->
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded mb-4" v-if="true">
<strong>Debug Pagination:</strong>
Total: {{ activityStore.total }},
Limit: {{ activityStore.limit }},
Pages: {{ activityStore.totalPages }},
Page courante: {{ activityStore.currentPage }},
Condition: {{ activityStore.total > activityStore.limit }}
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="overflow-x-auto">
<table class="min-w-full bg-white">

View File

@@ -48,6 +48,7 @@ final class ConvertFileController extends AbstractController
// Retourner le fichier converti
$fileContent = file_get_contents($response->convertedFilePath);
@unlink($response->convertedFilePath);
return new Response(
content: $fileContent,

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\Command;
use App\Domain\Shared\Domain\Contract\CommandInterface;
readonly class DeleteJob implements CommandInterface
{
public function __construct(
public string $id
) {
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\Command;
use App\Domain\Shared\Domain\Contract\CommandInterface;
use App\Domain\Shared\Domain\Model\JobStatus;
readonly class DeleteJobsByCriteria implements CommandInterface
{
/**
* @param JobStatus[]|null $statuses
*/
public function __construct(
public ?array $statuses = null,
public ?string $type = null,
public ?\DateTimeImmutable $createdAfter = null,
public ?\DateTimeImmutable $createdBefore = null,
) {
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\CommandHandler;
use App\Domain\Shared\Application\Command\DeleteJob;
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
use App\Domain\Shared\Domain\Contract\CommandInterface;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
readonly class DeleteJobHandler implements CommandHandlerInterface
{
public function __construct(
private JobRepositoryInterface $jobRepository
) {
}
public function handle(CommandInterface $command): void
{
if (!$command instanceof DeleteJob) {
throw new \InvalidArgumentException(sprintf(
'Command must be instance of %s, %s given',
DeleteJob::class,
get_class($command)
));
}
$this->jobRepository->get($command->id);
$this->jobRepository->delete($command->id);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\CommandHandler;
use App\Domain\Shared\Application\Command\DeleteJobsByCriteria;
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
use App\Domain\Shared\Domain\Contract\CommandInterface;
use App\Domain\Shared\Domain\Contract\JobRepositoryInterface;
readonly class DeleteJobsByCriteriaHandler implements CommandHandlerInterface
{
public function __construct(
private JobRepositoryInterface $jobRepository
) {
}
public function handle(CommandInterface $command): void
{
if (!$command instanceof DeleteJobsByCriteria) {
throw new \InvalidArgumentException(sprintf(
'Command must be instance of %s, %s given',
DeleteJobsByCriteria::class,
get_class($command)
));
}
$criteria = [];
if (null !== $command->statuses) {
$criteria['statuses'] = $command->statuses;
}
if (null !== $command->type) {
$criteria['type'] = $command->type;
}
if (null !== $command->createdAfter) {
$criteria['createdAfter'] = $command->createdAfter;
}
if (null !== $command->createdBefore) {
$criteria['createdBefore'] = $command->createdBefore;
}
$this->jobRepository->deleteByCriteria($criteria);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\Query;
use App\Domain\Shared\Domain\Contract\QueryInterface;
readonly class GetJobById implements QueryInterface
{
public function __construct(
public string $id
) {
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Application\QueryHandler;
use App\Domain\Shared\Application\Query\GetJobById;
use App\Domain\Shared\Application\Response\JobResponse;
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 GetJobByIdHandler implements QueryHandlerInterface
{
public function __construct(
private JobRepositoryInterface $jobRepository
) {
}
public function handle(QueryInterface $query): ResponseInterface
{
if (!$query instanceof GetJobById) {
throw new \InvalidArgumentException(sprintf(
'Query must be instance of %s, %s given',
GetJobById::class,
get_class($query)
));
}
$job = $this->jobRepository->get($query->id);
return JobResponse::fromJob($job);
}
}

View File

@@ -0,0 +1,41 @@
<?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 JobResponse implements ResponseInterface
{
public function __construct(
public string $id,
public string $type,
public string $status,
public \DateTimeImmutable $createdAt,
public ?\DateTimeImmutable $startedAt,
public ?\DateTimeImmutable $completedAt,
public ?string $failureReason,
public int $attempts,
public int $maxAttempts,
public array $context
) {
}
public static function fromJob(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

@@ -41,4 +41,17 @@ interface JobRepositoryInterface
* } $criteria
*/
public function countByCriteria(array $criteria): int;
public function delete(string $id): void;
/**
* @param array{
* statuses?: ?array<JobStatus>,
* type?: ?string,
* createdAfter?: ?\DateTimeImmutable,
* createdBefore?: ?\DateTimeImmutable
* } $criteria
* @return int Nombre de jobs supprimés
*/
public function deleteByCriteria(array $criteria): int;
}

View File

@@ -7,9 +7,16 @@ namespace App\Domain\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Domain\Shared\Application\Response\JobResponse;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Processor\DeleteJobProcessor;
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Processor\DeleteJobsByCriteriaProcessor;
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider\DeleteJobProvider;
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider\GetJobListStateProvider;
use App\Domain\Shared\Infrastructure\ApiPlatform\State\Provider\GetJobStateProvider;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
@@ -105,7 +112,56 @@ use Symfony\Component\Validator\Constraints as Assert;
]
]
]
)
),
new Get(
uriTemplate: '/jobs/{id}',
provider: GetJobStateProvider::class,
output: GetJobListResource::class,
description: 'Récupère un job par son identifiant',
),
new Delete(
uriTemplate: '/jobs/{id}',
provider: DeleteJobProvider::class,
processor: DeleteJobProcessor::class,
description: 'Supprime un job par son identifiant',
),
new Delete(
uriTemplate: '/jobs',
processor: DeleteJobsByCriteriaProcessor::class,
description: 'Supprime les jobs correspondant aux critères',
openapiContext: [
'parameters' => [
[
'name' => 'status',
'in' => 'query',
'description' => 'Filtrer par statut(s) (virgule-séparés ou tableau)',
'required' => false,
'schema' => ['type' => 'string'],
],
[
'name' => 'type',
'in' => 'query',
'description' => 'Filtrer par type de job',
'required' => false,
'schema' => ['type' => 'string'],
],
[
'name' => 'createdAfter',
'in' => 'query',
'description' => 'Date de création minimum (ISO8601)',
'required' => false,
'schema' => ['type' => 'string', 'format' => 'date-time'],
],
[
'name' => 'createdBefore',
'in' => 'query',
'description' => 'Date de création maximum (ISO8601)',
'required' => false,
'schema' => ['type' => 'string', 'format' => 'date-time'],
],
]
]
),
]
)]
class GetJobListResource
@@ -157,4 +213,20 @@ class GetJobListResource
context: $job->context
);
}
public static function fromJobResponse(JobResponse $response): self
{
return new self(
id: $response->id,
type: $response->type,
status: $response->status,
createdAt: $response->createdAt,
startedAt: $response->startedAt,
completedAt: $response->completedAt,
failureReason: $response->failureReason,
attempts: $response->attempts,
maxAttempts: $response->maxAttempts,
context: $response->context
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Shared\Application\Command\DeleteJob;
use App\Domain\Shared\Application\CommandHandler\DeleteJobHandler;
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DeleteJobProcessor implements ProcessorInterface
{
public function __construct(
private DeleteJobHandler $handler
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
try {
$this->handler->handle(new DeleteJob($uriVariables['id']));
} catch (JobNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage(), $e);
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Domain\Shared\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Shared\Application\Command\DeleteJobsByCriteria;
use App\Domain\Shared\Application\CommandHandler\DeleteJobsByCriteriaHandler;
use App\Domain\Shared\Domain\Model\JobStatus;
use Symfony\Component\HttpFoundation\RequestStack;
readonly class DeleteJobsByCriteriaProcessor implements ProcessorInterface
{
public function __construct(
private DeleteJobsByCriteriaHandler $handler,
private RequestStack $requestStack
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
{
$request = $this->requestStack->getCurrentRequest();
$filters = $request ? $request->query->all() : [];
$statuses = null;
if (isset($filters['status'])) {
$statusValues = is_array($filters['status'])
? $filters['status']
: explode(',', $filters['status']);
$statuses = [];
foreach ($statusValues as $value) {
try {
$statuses[] = JobStatus::from($value);
} catch (\ValueError) {
// ignore invalid values
}
}
if (empty($statuses)) {
$statuses = null;
}
}
$this->handler->handle(new DeleteJobsByCriteria(
statuses: $statuses,
type: $filters['type'] ?? null,
createdAfter: isset($filters['createdAfter']) ? new \DateTimeImmutable($filters['createdAfter']) : null,
createdBefore: isset($filters['createdBefore']) ? new \DateTimeImmutable($filters['createdBefore']) : null,
));
}
}

View File

@@ -0,0 +1,32 @@
<?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\GetJobById;
use App\Domain\Shared\Application\QueryHandler\GetJobByIdHandler;
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
use App\Domain\Shared\Infrastructure\ApiPlatform\Resource\GetJobListResource;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DeleteJobProvider implements ProviderInterface
{
public function __construct(
private GetJobByIdHandler $handler
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GetJobListResource
{
try {
$response = $this->handler->handle(new GetJobById($uriVariables['id']));
} catch (JobNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage(), $e);
}
return GetJobListResource::fromJobResponse($response);
}
}

View File

@@ -0,0 +1,32 @@
<?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\GetJobById;
use App\Domain\Shared\Application\QueryHandler\GetJobByIdHandler;
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
use App\Domain\Shared\Infrastructure\ApiPlatform\Resource\GetJobListResource;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class GetJobStateProvider implements ProviderInterface
{
public function __construct(
private GetJobByIdHandler $handler
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): GetJobListResource
{
try {
$response = $this->handler->handle(new GetJobById($uriVariables['id']));
} catch (JobNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage(), $e);
}
return GetJobListResource::fromJobResponse($response);
}
}

View File

@@ -188,4 +188,52 @@ readonly class DoctrineJobRepository implements JobRepositoryInterface
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function delete(string $id): void
{
$entity = $this->entityManager->find(JobEntity::class, $id);
if (null === $entity) {
throw JobNotFoundException::withId($id);
}
$this->entityManager->remove($entity);
$this->entityManager->flush();
}
public function deleteByCriteria(array $criteria): int
{
$qb = $this->entityManager->createQueryBuilder()
->delete(JobEntity::class, 'j');
if (isset($criteria['statuses']) && is_array($criteria['statuses']) && !empty($criteria['statuses'])) {
$expr = $qb->expr()->orX();
foreach ($criteria['statuses'] as $key => $status) {
$paramName = 'status' . $key;
$expr->add($qb->expr()->eq('j.status', ':' . $paramName));
$qb->setParameter($paramName, $status->value);
}
$qb->andWhere($expr);
} elseif (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()->execute();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Tests\Domain\Shared\Adapter;
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;
@@ -97,6 +98,26 @@ class InMemoryJobRepository implements JobRepositoryInterface
return count($this->findByCriteria($criteria));
}
public function delete(string $id): void
{
if (!isset($this->jobs[$id])) {
throw JobNotFoundException::withId($id);
}
unset($this->jobs[$id]);
}
public function deleteByCriteria(array $criteria): int
{
$toDelete = $this->findByCriteria($criteria);
foreach ($toDelete as $job) {
unset($this->jobs[$job->id]);
}
return count($toDelete);
}
public function clear(): void
{
$this->jobs = [];

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Tests\Domain\Shared\Application\CommandHandler;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Shared\Application\Command\DeleteJob;
use App\Domain\Shared\Application\CommandHandler\DeleteJobHandler;
use App\Domain\Shared\Domain\Exception\JobNotFoundException;
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
use PHPUnit\Framework\TestCase;
class DeleteJobHandlerTest extends TestCase
{
private InMemoryJobRepository $repository;
private DeleteJobHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryJobRepository();
$this->handler = new DeleteJobHandler($this->repository);
}
public function test_it_deletes_existing_job(): void
{
$job = new ScrapingJob('job-1', 'manga-1', 1.0, 'source-1');
$this->repository->save($job);
$this->handler->handle(new DeleteJob('job-1'));
$this->assertNull($this->repository->get('job-1'));
}
public function test_it_throws_when_job_not_found(): void
{
$this->expectException(JobNotFoundException::class);
$this->handler->handle(new DeleteJob('non-existent-id'));
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Tests\Domain\Shared\Application\CommandHandler;
use App\Domain\Scraping\Domain\Model\ScrapingJob;
use App\Domain\Shared\Application\Command\DeleteJobsByCriteria;
use App\Domain\Shared\Application\CommandHandler\DeleteJobsByCriteriaHandler;
use App\Domain\Shared\Domain\Model\JobStatus;
use App\Tests\Domain\Shared\Adapter\InMemoryJobRepository;
use PHPUnit\Framework\TestCase;
class DeleteJobsByCriteriaHandlerTest extends TestCase
{
private InMemoryJobRepository $repository;
private DeleteJobsByCriteriaHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryJobRepository();
$this->handler = new DeleteJobsByCriteriaHandler($this->repository);
}
public function test_it_deletes_jobs_by_status(): void
{
$job1 = new ScrapingJob('job-1', 'manga-1', 1.0, 'source-1');
$job2 = new ScrapingJob('job-2', 'manga-1', 2.0, 'source-1');
$job3 = new ScrapingJob('job-3', 'manga-1', 3.0, 'source-1');
$job2->complete();
$job3->complete();
$this->repository->save($job1);
$this->repository->save($job2);
$this->repository->save($job3);
$this->handler->handle(new DeleteJobsByCriteria(
statuses: [JobStatus::COMPLETED]
));
$this->assertNull($this->repository->get('job-2'));
$this->assertNull($this->repository->get('job-3'));
$this->assertNotNull($this->repository->get('job-1'));
}
public function test_it_deletes_jobs_by_type(): void
{
$job1 = new ScrapingJob('job-1', 'manga-1', 1.0, 'source-1');
$job2 = new ScrapingJob('job-2', 'manga-1', 2.0, 'source-1');
$this->repository->save($job1);
$this->repository->save($job2);
$this->handler->handle(new DeleteJobsByCriteria(
type: 'scraping_job'
));
$this->assertNull($this->repository->get('job-1'));
$this->assertNull($this->repository->get('job-2'));
}
public function test_it_does_nothing_when_no_criteria_match(): void
{
$job = new ScrapingJob('job-1', 'manga-1', 1.0, 'source-1');
$this->repository->save($job);
$this->handler->handle(new DeleteJobsByCriteria(
statuses: [JobStatus::FAILED]
));
$this->assertNotNull($this->repository->get('job-1'));
}
}

View File

@@ -75,12 +75,4 @@ class DeleteMangaTest extends AbstractApiTestCase
$this->assertResponseStatusCodeSame(404);
}
public function test_it_returns_404_for_missing_id(): void
{
// When
static::createClient()->request('DELETE', '/api/mangas/');
// Then
$this->assertResponseStatusCodeSame(404);
}
}