feat(monorepo): migrate to typescript monorepo

This commit is contained in:
2025-11-07 17:09:29 +02:00
committed by BernardNganduDev
parent 3e09956f05
commit 075a388ccb
745 changed files with 2341 additions and 5082 deletions
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\EventListener;
use Basango\Aggregator\Application\Mailing\SourceCrawledEmail;
use Basango\Aggregator\Domain\Event\SourceCrawled;
use Basango\SharedKernel\Application\Mailing\Mailer;
use Basango\SharedKernel\Domain\EventListener\EventListener;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class SourceFetchedListener.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SourceCrawledListener implements EventListener
{
public function __construct(
private Mailer $mailer,
private string $crawlingNotificationEmail
) {
}
public function __invoke(SourceCrawled $event): void
{
if ($event->notify) {
$email = new SourceCrawledEmail(
EmailAddress::from($this->crawlingNotificationEmail),
$event->event,
$event->source
);
$this->mailer->send($email);
}
}
}
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\Mailing;
use Basango\SharedKernel\Application\Mailing\EmailDefinition;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class SourceFetched.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SourceCrawledEmail implements EmailDefinition
{
public function __construct(
private EmailAddress $recipient,
private string $event,
private string $source,
) {
}
#[\Override]
public function recipient(): EmailAddress
{
return $this->recipient;
}
#[\Override]
public function subject(): string
{
return 'aggregator.emails.source_crawled.subject';
}
#[\Override]
public function subjectVariables(): array
{
return [];
}
#[\Override]
public function template(): string
{
return 'aggregator/source_crawled';
}
#[\Override]
public function templateVariables(): array
{
return [
'source' => $this->source,
'event' => $this->event,
];
}
#[\Override]
public function locale(): string
{
return 'fr';
}
#[\Override]
public function getDomain(): string
{
return 'aggregator';
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\ReadModel;
use Basango\Aggregator\Domain\Model\Identity\ArticleId;
use Basango\SharedKernel\Domain\DataTransfert\DataMapping;
/**
* Class ExportedArticle.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ArticleForExport
{
public function __construct(
public ArticleId $id,
public string $title,
public string $link,
public string $categories,
public string $body,
public string $source,
public string $hash,
public \DateTimeImmutable $publishedAt,
public \DateTimeImmutable $crawledAt
) {
}
public static function create(array $item): self
{
return new self(
ArticleId::fromString(DataMapping::string($item, 'article_id')),
DataMapping::string($item, 'article_title'),
DataMapping::string($item, 'article_link'),
DataMapping::string($item, 'article_categories'),
DataMapping::string($item, 'article_body'),
DataMapping::string($item, 'article_source'),
DataMapping::string($item, 'article_hash'),
DataMapping::datetime($item, 'article_published_at'),
DataMapping::datetime($item, 'article_crawled_at')
);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\ReadModel;
use Basango\Aggregator\Domain\Model\Identity\SourceId;
use Basango\SharedKernel\Domain\DataTransfert\DataMapping;
/**
* Class SourceStatistics.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SourceStatistics
{
public function __construct(
public SourceId $id,
public string $name,
public int $articlesCount,
public int $metadataAvailable,
public ?\DateTimeImmutable $crawledAt = null
) {
}
public static function create(array $item): self
{
return new self(
SourceId::fromString(DataMapping::string($item, 'source_id')),
DataMapping::string($item, 'source_name'),
DataMapping::integer($item, 'articles_count'),
DataMapping::integer($item, 'articles_metadata_available'),
DataMapping::nullableDatetime($item, 'source_crawled_at')
);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\ReadModel;
use Basango\SharedKernel\Domain\Assert;
/**
* Class SourceStatisticsList.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SourceStatisticsList
{
public function __construct(
public array $items,
) {
Assert::allIsInstanceOf($items, SourceStatistics::class);
}
public static function create(array $items): self
{
return new self(
array_map(fn (array $item): SourceStatistics => SourceStatistics::create($item), $items),
);
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\Command;
use Basango\Aggregator\Domain\Model\ValueObject\Link;
use Basango\Aggregator\Domain\Model\ValueObject\OpenGraph;
use Basango\Aggregator\Domain\Model\ValueObject\TokenStatistics;
/**
* Class Save.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class CreateArticle
{
public function __construct(
public string $title,
public Link $link,
public array $categories,
public string $body,
public string $source,
public int $timestamp,
public ?OpenGraph $metadata = null,
public ?TokenStatistics $tokenStatistics = null
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\Command;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Credibility;
/**
* Class CreateSource.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class CreateSource
{
public function __construct(
public string $name,
public Credibility $credibility,
public ?string $displayName = null,
public ?string $description = null
) {
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\Command;
/**
* Class DeleteArticles.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class DeleteArticles
{
public function __construct(
public string $source,
public ?string $category = null
) {
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\Command;
use Basango\SharedKernel\Domain\Model\ValueObject\DateRange;
/**
* Class Export.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ExportArticles
{
public function __construct(
public ?string $source = null,
public ?DateRange $date = null
) {
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\CommandHandler;
use Basango\Aggregator\Application\UseCase\Command\CreateArticle;
use Basango\Aggregator\Domain\Exception\DuplicatedArticle;
use Basango\Aggregator\Domain\Model\Entity\Article;
use Basango\Aggregator\Domain\Model\Repository\ArticleRepository;
use Basango\Aggregator\Domain\Model\Repository\SourceRepository;
use Basango\Aggregator\Domain\Service\HashCalculator;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
/**
* Class CreateArticlesHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class CreateArticleHandler implements CommandHandler
{
public function __construct(
private SourceRepository $sourceRepository,
private ArticleRepository $articleRepository,
private HashCalculator $hashCalculator
) {
}
public function __invoke(CreateArticle $command): void
{
$hash = $this->hashCalculator->calculate((string) $command->link);
$article = $this->articleRepository->getByHash($hash);
if ($article instanceof Article) {
throw DuplicatedArticle::withLink($command->link);
}
/** @var \DateTimeImmutable $publishedAt */
$publishedAt = \DateTimeImmutable::createFromFormat('U', (string) $command->timestamp);
$source = $this->sourceRepository->getByName($command->source);
$article = new Article(
title: $command->title,
link: $command->link,
body: $command->body,
hash: $hash,
categories: $command->categories,
source: $source,
publishedAt: $publishedAt
);
$article
->defineOpenGraph($command->metadata)
->defineTokenStatistics($command->tokenStatistics)
->computeReadingTime();
$this->articleRepository->add($article);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\CommandHandler;
use Basango\Aggregator\Application\UseCase\Command\CreateSource;
use Basango\Aggregator\Domain\Model\Entity\Source;
use Basango\Aggregator\Domain\Model\Repository\SourceRepository;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
/**
* Class AddSourceHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class CreateSourceHandler implements CommandHandler
{
public function __construct(
private SourceRepository $sourceRepository
) {
}
public function __invoke(CreateSource $command): void
{
$source = Source::create($command->name, sprintf('https://%s', $command->name))
->defineCredibility($command->credibility)
->defineProfileInfos($command->displayName, $command->description);
$this->sourceRepository->add($source);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\CommandHandler;
use Basango\Aggregator\Application\UseCase\Command\DeleteArticles;
use Basango\Aggregator\Domain\Model\Repository\ArticleRepository;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
/**
* Class DeleteArticlesHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class DeleteArticlesHandler implements CommandHandler
{
public function __construct(
private ArticleRepository $articleRepository,
) {
}
public function __invoke(DeleteArticles $command): int
{
return $this->articleRepository->clear($command->source, $command->category);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\CommandHandler;
use Basango\Aggregator\Application\ReadModel\ArticleForExport;
use Basango\Aggregator\Application\UseCase\Command\ExportArticles;
use Basango\Aggregator\Application\UseCase\Query\GetArticlesForExport;
use Basango\SharedKernel\Application\Messaging\CommandHandler;
use Basango\SharedKernel\Application\Messaging\QueryBus;
use Basango\SharedKernel\Domain\DataTransfert\DataExporter;
use Basango\SharedKernel\Domain\DataTransfert\TransfertSetting;
/**
* Class GetArticlesForExportHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ExportArticlesHandler implements CommandHandler
{
public function __construct(
private QueryBus $queryBus,
private DataExporter $exporter,
private string $projectDir
) {
}
public function __invoke(ExportArticles $command): void
{
$filename = sprintf(
'%s/data/export-%s.csv',
$this->projectDir,
new \DateTimeImmutable('now')->format('U')
);
/** @var iterable<ArticleForExport> $articles */
$articles = $this->queryBus->handle(new GetArticlesForExport($command->source, $command->date));
$this->exporter->export($articles, new TransfertSetting($filename));
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\Query;
use Basango\SharedKernel\Domain\Model\ValueObject\DateRange;
/**
* Class GetArticlesForExport.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetArticlesForExport
{
public function __construct(
public ?string $source = null,
public ?DateRange $date = null
) {
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\Query;
/**
* Class GetEarliestPublicationDate.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetEarliestPublicationDate
{
public function __construct(
public string $source,
public ?string $category = null
) {
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\Query;
/**
* Class GetLatestPublicationDate.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetLatestPublicationDate
{
public function __construct(
public string $source,
public ?string $category = null
) {
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\Query;
/**
* Class GetSourceStatisticsList.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetSourceStatisticsList
{
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\QueryHandler;
use Basango\Aggregator\Application\ReadModel\ArticleForExport;
use Basango\Aggregator\Application\UseCase\Query\GetArticlesForExport;
use Basango\SharedKernel\Application\Messaging\QueryHandler;
/**
* Class GetArticlesForExportHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface GetArticlesForExportHandler extends QueryHandler
{
/**
* @return iterable<ArticleForExport>
*/
public function __invoke(GetArticlesForExport $query): iterable;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\QueryHandler;
use Basango\Aggregator\Application\UseCase\Query\GetEarliestPublicationDate;
use Basango\SharedKernel\Application\Messaging\QueryHandler;
/**
* Interface GetEarliestPublicationDateHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface GetEarliestPublicationDateHandler extends QueryHandler
{
public function __invoke(GetEarliestPublicationDate $query): \DateTimeImmutable;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\QueryHandler;
use Basango\Aggregator\Application\UseCase\Query\GetLatestPublicationDate;
use Basango\SharedKernel\Application\Messaging\QueryHandler;
/**
* Interface GetLatestPublicationDateHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface GetLatestPublicationDateHandler extends QueryHandler
{
public function __invoke(GetLatestPublicationDate $query): \DateTimeImmutable;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Application\UseCase\QueryHandler;
use Basango\Aggregator\Application\ReadModel\SourceStatisticsList;
use Basango\Aggregator\Application\UseCase\Query\GetSourceStatisticsList;
use Basango\SharedKernel\Application\Messaging\QueryHandler;
/**
* Interface GetSourceStatisticsListHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface GetSourceStatisticsListHandler extends QueryHandler
{
public function __invoke(GetSourceStatisticsList $query): SourceStatisticsList;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Event;
/**
* Class SourceFetched.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class SourceCrawled
{
public function __construct(
public string $event,
public string $source,
public bool $notify = false
) {
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Exception;
use Basango\Aggregator\Domain\Model\Identity\ArticleId;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class ArticleNotFound.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class ArticleNotFound extends \DomainException implements UserFacingError
{
public static function withId(ArticleId $id): self
{
return new self(sprintf('article with id %s was not found', $id->toString()));
}
public function translationId(): string
{
return 'aggregator.exceptions.article_not_found';
}
public function translationParameters(): array
{
return [];
}
public function translationDomain(): string
{
return 'aggregator';
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
use Basango\SharedKernel\Domain\Model\ValueObject\DateRange;
/**
* Class ArticleOutOfRange.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class ArticleOutOfRange extends \DomainException implements UserFacingError
{
public static function with(string $timestamp, DateRange $dateRange): self
{
$date = new \DateTimeImmutable('@' . $timestamp)
->format('Y-m-d H:i:s');
$range = $dateRange->format('Y-m-d H:i:s');
return new self(sprintf('article with timestamp %s is out of range %s', $date, $range));
}
public function translationId(): string
{
return 'aggregator.exceptions.article_out_of_range';
}
public function translationParameters(): array
{
return [];
}
public function translationDomain(): string
{
return 'aggregator';
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Exception;
use Basango\Aggregator\Domain\Model\ValueObject\Link;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class DuplicatedArticle.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class DuplicatedArticle extends \DomainException implements UserFacingError
{
public static function withLink(Link $link): self
{
return new self(sprintf('duplicate article with %s link', (string) $link));
}
public function translationId(): string
{
return 'aggregator.exceptions.duplicate_article';
}
public function translationParameters(): array
{
return [];
}
public function translationDomain(): string
{
return 'aggregator';
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Exception;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class DuplicatedArticle.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class DuplicatedSource extends \DomainException implements UserFacingError
{
public static function withName(string $name): self
{
return new self(sprintf('duplicate source with %s name', $name));
}
public function translationId(): string
{
return 'aggregator.exceptions.duplicate_source';
}
public function translationParameters(): array
{
return [];
}
public function translationDomain(): string
{
return 'aggregator';
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Exception;
use Basango\Aggregator\Domain\Model\Identity\SourceId;
use Basango\SharedKernel\Domain\Exception\UserFacingError;
/**
* Class SourceNotFound.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class SourceNotFound extends \DomainException implements UserFacingError
{
public static function withName(string $name): self
{
return new self(sprintf('source with name %s was not found', $name));
}
public static function withId(SourceId $sourceId): self
{
return new self(sprintf('source with id %s was not found', $sourceId->toString()));
}
public function translationId(): string
{
return 'aggregator.exceptions.source_not_found';
}
public function translationParameters(): array
{
return [];
}
public function translationDomain(): string
{
return 'aggregator';
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\Entity;
use Basango\Aggregator\Domain\Model\Identity\ArticleId;
use Basango\Aggregator\Domain\Model\ValueObject\Link;
use Basango\Aggregator\Domain\Model\ValueObject\OpenGraph;
use Basango\Aggregator\Domain\Model\ValueObject\ReadingTime;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Credibility;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Sentiment;
use Basango\Aggregator\Domain\Model\ValueObject\TokenStatistics;
/**
* Class Article.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class Article
{
public readonly ArticleId $id;
public function __construct(
public readonly string $title,
public readonly Link $link,
public readonly string $body,
public readonly string $hash,
private(set) array $categories,
public readonly Source $source,
public readonly \DateTimeImmutable $publishedAt,
public readonly \DateTimeImmutable $crawledAt = new \DateTimeImmutable(),
private(set) Credibility $credibility = new Credibility(),
private(set) Sentiment $sentiment = Sentiment::NEUTRAL,
private(set) ?OpenGraph $metadata = null,
private(set) ?TokenStatistics $tokenStatistics = null,
private(set) ?ReadingTime $readingTime = null,
private(set) ?\DateTimeImmutable $updatedAt = null,
public readonly ?string $image = null,
public readonly ?string $excerpt = null,
) {
$this->id = new ArticleId();
}
public function defineCredibility(Credibility $credibility): self
{
$this->credibility = $credibility;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function defineSentiment(Sentiment $sentiment): self
{
$this->sentiment = $sentiment;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function assignCategories(array $categories): self
{
$this->categories = $categories;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function computeReadingTime(): self
{
$this->readingTime = ReadingTime::fromContent($this->body);
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function defineOpenGraph(?OpenGraph $object): self
{
$this->metadata = new OpenGraph(
title: $object?->title,
description: $object?->description,
image: $object?->image,
locale: $object->locale ?? 'fr'
);
return $this;
}
public function defineTokenStatistics(?TokenStatistics $statistics): self
{
$this->tokenStatistics = $statistics;
return $this;
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\Entity;
use Basango\Aggregator\Domain\Model\Identity\CategoryId;
/**
* Class Category.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class Category
{
public readonly CategoryId $id;
public function __construct(
public string $name,
public string $slug,
public array $children = [],
public ?string $description = null,
public ?string $image = null,
) {
$this->id = new CategoryId();
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\Entity;
use Basango\Aggregator\Domain\Model\Identity\SourceId;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Credibility;
/**
* Class Source.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class Source
{
public readonly SourceId $id;
public function __construct(
public readonly string $name,
public readonly string $url,
private(set) Credibility $credibility = new Credibility(),
private(set) ?string $displayName = null,
private(set) ?string $description = null,
private(set) ?\DateTimeImmutable $updatedAt = null
) {
$this->id = new SourceId();
}
public static function create(string $name, string $url): self
{
return new self($name, $url);
}
public function defineCredibility(Credibility $credibility): self
{
$this->credibility = $credibility;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function defineProfileInfos(?string $displayName, ?string $description): self
{
$this->displayName = $displayName;
$this->description = $description;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class ArticleId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class ArticleId extends UuidV7
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class CategoryId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class CategoryId extends UuidV7
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class SourceId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class SourceId extends UuidV7
{
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\Repository;
use Basango\Aggregator\Domain\Model\Entity\Article;
use Basango\Aggregator\Domain\Model\Identity\ArticleId;
use Basango\SharedKernel\Domain\Model\ValueObject\DateRange;
/**
* Interface ArticleRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface ArticleRepository
{
public function add(Article $article): void;
public function remove(Article $article): void;
public function getById(ArticleId $id): Article;
public function getByHash(string $hash): ?Article;
public function export(?string $source, ?DateRange $date): \Generator;
public function clear(string $source, ?string $category): int;
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\Repository;
use Basango\Aggregator\Domain\Model\Entity\Source;
use Basango\Aggregator\Domain\Model\Identity\SourceId;
/**
* Interface SourceRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface SourceRepository
{
public function add(Source $source): void;
public function remove(Source $source): void;
public function getByName(string $name): Source;
public function getById(SourceId $sourceId): Source;
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject;
use Basango\SharedKernel\Domain\Assert;
/**
* Class Link.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class Link implements \Stringable, \JsonSerializable
{
public string $link;
private function __construct(string $url, ?string $source = null)
{
if (! str_starts_with($url, 'http')) {
Assert::notNull($source, 'You must provide a source if the URL is not absolute.');
$this->link = sprintf('https://%s/%s', $source, trim($url, '/'));
} else {
$this->link = $url;
}
}
public function __toString(): string
{
return $this->link;
}
public static function from(string $url, ?string $source = null): self
{
return new self($url, $source);
}
public function jsonSerialize(): string
{
return $this->link;
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject;
/**
* Class OpenGraphMeta.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class OpenGraph implements \JsonSerializable
{
public function __construct(
public ?string $title = null,
public ?string $description = null,
public ?string $image = null,
public ?string $video = null,
public ?string $audio = null,
public ?string $locale = null,
) {
}
public static function tryFrom(?string $value): ?self
{
if ($value === null) {
return null;
}
try {
$object = \json_decode($value, true, 512, JSON_THROW_ON_ERROR);
return new self(
$object['title'] ?? null,
$object['description'] ?? null,
$object['image'] ?? null,
$object['video'] ?? null,
$object['audio'] ?? null,
$object['locale'] ?? null,
);
} catch (\Throwable) {
return null;
}
}
#[\Override]
public function jsonSerialize(): array
{
return [
'title' => $this->title,
'description' => $this->description,
'image' => $this->image,
'video' => $this->video,
'audio' => $this->audio,
'locale' => $this->locale,
];
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject;
/**
* Class ReadingTime.
*
* The average reading rate is actually 238, but 200 is a nice compromise and is easier to remember.
*
* Heres the formula:
* Get your total word count (including the headline and subhead).
* Divide total word count by 200. The number before the decimal is your minutes.
* Take the decimal points and multiply that number by .60. That will give you your seconds.
*
* Example:
* 783 words ÷ 200 = 3.915 (3 = 3 minutes)
* .915 × .60 = .549 (a little over 54 seconds, so Id bump it up to 60 seconds, or a full minute)
* reading time for this example article is 4 minutes
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class ReadingTime implements \Stringable, \JsonSerializable
{
public const int WORDS_PER_MINUTE = 200;
public int $readingTime;
public function __construct(
string|int $value
) {
$this->readingTime = is_string($value) ? intval(str_word_count($value) / self::WORDS_PER_MINUTE) : $value;
}
public function __toString(): string
{
return (string) $this->readingTime;
}
public static function create(?int $value): self
{
return new self($value ?? 1);
}
public static function fromContent(string $content): self
{
return new self($content);
}
public function jsonSerialize(): string
{
return (string) $this->readingTime;
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject\Scoring;
/**
* Class Bias.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum Bias: string
{
case NEUTRAL = 'neutral';
case SLIGHTLY = 'slightly';
case PARTISAN = 'partisan';
case EXTREME = 'extreme';
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject\Scoring;
/**
* Class Credibility.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class Credibility implements \JsonSerializable
{
public function __construct(
public Bias $bias = Bias::NEUTRAL,
public Reliability $reliability = Reliability::RELIABLE,
public Transparency $transparency = Transparency::MEDIUM
) {
}
public function jsonSerialize(): mixed
{
return [
'bias' => $this->bias->value,
'reliability' => $this->reliability->value,
'transparency' => $this->transparency->value,
];
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject\Scoring;
/**
* Class Reliability.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum Reliability: string
{
case TRUSTED = 'trusted';
case RELIABLE = 'reliable';
case AVERAGE = 'average';
case LOW_TRUST = 'low_trust';
case UNRELIABLE = 'unreliable';
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject\Scoring;
/**
* Enum Sentiment.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum Sentiment: string
{
case NEGATIVE = 'negative';
case POSITIVE = 'positive';
case NEUTRAL = 'neutral';
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject\Scoring;
/**
* Enum Transparency.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum Transparency: string
{
case HIGH = 'high';
case MEDIUM = 'medium';
case LOW = 'low';
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Model\ValueObject;
/**
* Class TokenStatistics.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class TokenStatistics implements \JsonSerializable
{
public ?int $total {
get {
return ($this->title ?? 0)
+ ($this->body ?? 0)
+ ($this->excerpt ?? 0)
+ ($this->categories ?? 0);
}
}
public function __construct(
public readonly ?int $title = null,
public readonly ?int $body = null,
public readonly ?int $excerpt = null,
public readonly ?int $categories = null,
) {
}
public static function tryFrom(?string $value): ?self
{
if ($value === null) {
return null;
}
try {
$object = \json_decode($value, true, 512, JSON_THROW_ON_ERROR);
return new self(
$object['title'] ?? null,
$object['body'] ?? null,
$object['excerpt'] ?? null,
$object['categories'] ?? null,
);
} catch (\Throwable) {
return null;
}
}
#[\Override]
public function jsonSerialize(): array
{
return [
'title' => $this->title,
'body' => $this->body,
'excerpt' => $this->excerpt,
'categories' => $this->categories,
'total' => $this->total,
];
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Service;
/**
* Class HashCalculator.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class HashCalculator
{
public function calculate(string $data): string
{
return md5($data);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Service\Scoring;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Bias;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Credibility;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Reliability;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Transparency;
/**
* Interface CredibilityAnalyser.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface CredibilityAnalyser
{
public function getBias(string $content): Bias;
public function getTransparency(string $content): Transparency;
public function getReliability(string $content): Reliability;
public function analyse(string $content): Credibility;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Domain\Service\Scoring;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Sentiment;
/**
* Interface SentimentAnalyser.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface SentimentAnalyser
{
public function analyse(string $content): Sentiment;
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\DBAL;
use Basango\Aggregator\Application\ReadModel\ArticleForExport;
use Basango\Aggregator\Application\UseCase\Query\GetArticlesForExport;
use Basango\Aggregator\Application\UseCase\QueryHandler\GetArticlesForExportHandler;
use Basango\SharedKernel\Domain\Model\ValueObject\DateRange;
use Doctrine\DBAL\Connection;
/**
* Class GetArticlesForExportDbalHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetArticlesForExportDbalHandler implements GetArticlesForExportHandler
{
private const int BATCH_SIZE = 1000;
public function __construct(
private Connection $connection
) {
}
#[\Override]
public function __invoke(GetArticlesForExport $query): iterable
{
$qb = $this->connection->createQueryBuilder()
->select(
'a.id as article_id',
'a.title as article_title',
'a.link as article_link',
"array_to_string(a.categories, ',') as article_categories",
'a.body as article_body',
's.name as article_source',
'a.hash as article_hash',
'a.published_at as article_published_at',
'a.crawled_at as article_crawled_at'
)
->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->orderBy('a.published_at', 'DESC')
->addOrderBy('a.id', 'DESC');
if ($query->source !== null) {
$qb->andWhere('s.name = :source')
->setParameter('source', $query->source);
}
if ($query->date instanceof DateRange) {
$qb->andWhere('a.published_at BETWEEN to_timestamp(:start) AND to_timestamp(:end)')
->setParameter('start', $query->date->start)
->setParameter('end', $query->date->end);
}
$offset = 0;
while (true) {
$qb->setFirstResult($offset);
$qb->setMaxResults(self::BATCH_SIZE);
/** @var array<array<string, mixed>> $data */
$data = $qb->executeQuery()->fetchAllAssociative();
if (count($data) === 0) {
break;
}
foreach ($data as $article) {
yield ArticleForExport::create($article);
}
$offset += self::BATCH_SIZE;
}
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\DBAL;
use Basango\Aggregator\Application\UseCase\Query\GetEarliestPublicationDate;
use Basango\Aggregator\Application\UseCase\QueryHandler\GetEarliestPublicationDateHandler;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
/**
* Class GetEarliestPublicationDateDBalHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetEarliestPublicationDateDBalHandler implements GetEarliestPublicationDateHandler
{
public function __construct(
private Connection $connection,
private LoggerInterface $logger
) {
}
#[\Override]
public function __invoke(GetEarliestPublicationDate $query): \DateTimeImmutable
{
$qb = $this->connection->createQueryBuilder()
->select('MIN(a.published_at)')
->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where('s.name = :source')
->setParameter('source', $query->source);
if ($query->category !== null) {
$qb->andWhere(':category = ANY(a.categories)')
->setParameter('category', $query->category);
}
try {
/** @var string|null $date */
$date = $qb->executeQuery()->fetchOne();
return new \DateTimeImmutable($date ?? 'now');
} catch (\Throwable $e) {
$this->logger->critical('Unable to fetch earliest publication date');
throw NoResult::forQuery($qb->getSQL(), $qb->getParameters(), $e);
}
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\DBAL;
use Basango\Aggregator\Application\UseCase\Query\GetLatestPublicationDate;
use Basango\Aggregator\Application\UseCase\QueryHandler\GetLatestPublicationDateHandler;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
/**
* Class GetLatestPublicationDateDBalHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetLatestPublicationDateDBalHandler implements GetLatestPublicationDateHandler
{
public function __construct(
private Connection $connection,
private LoggerInterface $logger
) {
}
#[\Override]
public function __invoke(GetLatestPublicationDate $query): \DateTimeImmutable
{
$qb = $this->connection->createQueryBuilder()
->select('MAX(a.published_at)')
->from('article', 'a')
->innerJoin('a', 'source', 's', 'a.source_id = s.id')
->where('s.name = :source')
->setParameter('source', $query->source);
if ($query->category !== null) {
$qb->andWhere(':category = ANY(a.categories)')
->setParameter('category', $query->category);
}
try {
/** @var string|null $date */
$date = $qb->executeQuery()->fetchOne();
return new \DateTimeImmutable($date ?? 'now');
} catch (\Throwable $e) {
$this->logger->critical('Unable to fetch latest publication date');
throw NoResult::forQuery($qb->getSQL(), $qb->getParameters(), $e);
}
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\DBAL;
use Basango\Aggregator\Application\ReadModel\SourceStatisticsList;
use Basango\Aggregator\Application\UseCase\Query\GetSourceStatisticsList;
use Basango\Aggregator\Application\UseCase\QueryHandler\GetSourceStatisticsListHandler;
use Basango\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\NoResult;
use Doctrine\DBAL\Connection;
/**
* Class GetSourceStatisticsListDbalHandler.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GetSourceStatisticsListDbalHandler implements GetSourceStatisticsListHandler
{
public function __construct(
private Connection $connection
) {
}
public function __invoke(GetSourceStatisticsList $query): SourceStatisticsList
{
$qb = $this->connection->createQueryBuilder()
->select(
's.id as source_id',
's.name as source_name',
'MAX(a.crawled_at) as source_crawled_at',
'COUNT(a.id) as articles_count',
'SUM(CASE WHEN a.metadata IS NOT NULL THEN 1 ELSE 0 END) as article_metadata_available'
)
->from('source', 's')
->leftJoin('s', 'article', 'a', 'a.source_id = s.id')
->groupBy('s.id, s.name')
->orderBy('s.name', 'ASC');
try {
$data = $qb->executeQuery()->fetchAllAssociative();
} catch (\Throwable $e) {
throw NoResult::forQuery($qb->getSQL(), $qb->getParameters(), $e);
}
return SourceStatisticsList::create($data);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\DBAL\Types;
use Basango\Aggregator\Domain\Model\Identity\ArticleId;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
/**
* Class ArticleId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class ArticleIdType extends AbstractUidType
{
#[\Override]
public function getName(): string
{
return 'article_id';
}
#[\Override]
protected function getUidClass(): string
{
return ArticleId::class;
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\DBAL\Types;
use Basango\Aggregator\Domain\Model\ValueObject\OpenGraph;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
/**
* Class OpenGraphType.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class OpenGraphType extends Type
{
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getJsonTypeDeclarationSQL([
'nullable' => true,
'jsonb' => true,
]);
}
public function getName(): string
{
return 'open_graph';
}
#[\Override]
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?OpenGraph
{
if ($value === null) {
return null;
}
if (! \is_string($value)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', OpenGraph::class]);
}
try {
return OpenGraph::tryFrom($value);
} catch (\Throwable $e) {
throw ConversionException::conversionFailed($value, $this->getName(), $e);
}
}
#[\Override]
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value instanceof OpenGraph) {
return json_encode($value) ?: null;
}
if ($value === null || $value === '') {
return null;
}
if (! \is_string($value)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', OpenGraph::class]);
}
throw ConversionException::conversionFailed($value, $this->getName());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\DBAL\Types;
use Basango\Aggregator\Domain\Model\Identity\SourceId;
use Symfony\Bridge\Doctrine\Types\AbstractUidType;
/**
* Class SourceId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class SourceIdType extends AbstractUidType
{
public function getName(): string
{
return 'source_id';
}
protected function getUidClass(): string
{
return SourceId::class;
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\DBAL\Types;
use Basango\Aggregator\Domain\Model\ValueObject\TokenStatistics;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
/**
* Class TokenStatisticsType.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class TokenStatisticsType extends Type
{
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getJsonTypeDeclarationSQL([
'nullable' => true,
'jsonb' => true,
]);
}
public function getName(): string
{
return 'token_statistics';
}
#[\Override]
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?TokenStatistics
{
if ($value === null) {
return null;
}
if (! \is_string($value)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', TokenStatistics::class]);
}
try {
return TokenStatistics::tryFrom($value);
} catch (\Throwable $e) {
throw ConversionException::conversionFailed($value, $this->getName(), $e);
}
}
#[\Override]
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value instanceof TokenStatistics) {
return json_encode($value) ?: null;
}
if ($value === null || $value === '') {
return null;
}
if (! \is_string($value)) {
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', TokenStatistics::class]);
}
throw ConversionException::conversionFailed($value, $this->getName());
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\ORM;
use Basango\Aggregator\Domain\Exception\ArticleNotFound;
use Basango\Aggregator\Domain\Model\Entity\Article;
use Basango\Aggregator\Domain\Model\Identity\ArticleId;
use Basango\Aggregator\Domain\Model\Repository\ArticleRepository;
use Basango\SharedKernel\Domain\Model\ValueObject\DateRange;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class ArticleOrmRepository.
*
* @extends ServiceEntityRepository<Article>
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class ArticleOrmRepository extends ServiceEntityRepository implements ArticleRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
#[\Override]
public function add(Article $article): void
{
$this->getEntityManager()->persist($article);
$this->getEntityManager()->flush();
}
#[\Override]
public function remove(Article $article): void
{
$this->getEntityManager()->remove($article);
$this->getEntityManager()->flush();
}
#[\Override]
public function getById(ArticleId $id): Article
{
/** @var Article|null $article */
$article = $this->findOneBy([
'id' => $id,
]);
if ($article === null) {
throw ArticleNotFound::withId($id);
}
return $article;
}
#[\Override]
public function export(?string $source, ?DateRange $date): \Generator
{
$qb = $this->createQueryBuilder('a')
->orderBy('a.publishedAt', 'DESC');
if ($source !== null) {
$qb
->leftJoin('a.source', 's')
->andWhere('s.name = :source')
->setParameter('source', $source);
}
if ($date instanceof DateRange) {
$qb->andWhere('a.publishedAt BETWEEN :startDate AND :endDate')
->setParameter('startDate', new DateTimeImmutable()->setTimestamp($date->start))
->setParameter('endDate', new DateTimeImmutable()->setTimestamp($date->end));
}
$limit = 1000;
$offset = 0;
while (true) {
$qb->setFirstResult($offset);
$qb->setMaxResults($limit);
/** @var Article[] $articles */
$articles = $qb->getQuery()->getResult();
if (count($articles) === 0) {
break;
}
foreach ($articles as $article) {
yield $article;
$this->getEntityManager()->detach($article);
}
$offset += $limit;
}
}
#[\Override]
public function getByHash(string $hash): ?Article
{
/** @var Article|null $article */
$article = $this->findOneBy([
'hash' => $hash,
]);
return $article;
}
#[\Override]
public function clear(string $source, ?string $category): int
{
$qb = $this->createQueryBuilder('a')
->leftJoin('a.source', 's')
->where('s.name = :source')
->setParameter('source', $source);
if ($category !== null) {
$qb->andWhere('a.categories LIKE :category')
->setParameter('category', sprintf('%%%s%%', $category));
}
/** @var int $result */
$result = $qb->delete(Article::class, 'a')
->getQuery()
->execute();
return $result;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Infrastructure\Persistence\Doctrine\ORM;
use Basango\Aggregator\Domain\Exception\SourceNotFound;
use Basango\Aggregator\Domain\Model\Entity\Source;
use Basango\Aggregator\Domain\Model\Identity\SourceId;
use Basango\Aggregator\Domain\Model\Repository\SourceRepository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* Class SourceOrmRepository.
*
* @extends ServiceEntityRepository<Source>
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class SourceOrmRepository extends ServiceEntityRepository implements SourceRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Source::class);
}
public function add(Source $source): void
{
$this->getEntityManager()->persist($source);
$this->getEntityManager()->flush();
}
public function remove(Source $source): void
{
$this->getEntityManager()->remove($source);
$this->getEntityManager()->flush();
}
public function getByName(string $name): Source
{
$source = $this->findOneBy([
'name' => $name,
]);
if ($source === null) {
throw SourceNotFound::withName($name);
}
return $source;
}
public function getById(SourceId $sourceId): Source
{
$source = $this->findOneBy([
'id' => $sourceId,
]);
if ($source === null) {
throw SourceNotFound::withId($sourceId);
}
return $source;
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Presentation\Console;
use Basango\Aggregator\Application\UseCase\Command\CreateSource;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Bias;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Credibility;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Reliability;
use Basango\Aggregator\Domain\Model\ValueObject\Scoring\Transparency;
use Basango\SharedKernel\Application\Messaging\CommandBus;
use Basango\SharedKernel\Presentation\Console\AskArgumentFeature;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:create-source',
description: 'add a new data source'
)]
class CreateSourceConsole extends Command
{
use AskArgumentFeature;
private SymfonyStyle $io;
public function __construct(
private readonly CommandBus $commandBus
) {
parent::__construct();
}
#[\Override]
protected function configure(): void
{
$this->addArgument('source', InputArgument::REQUIRED, 'the website source to crawle');
$this->addArgument('displayName', InputArgument::OPTIONAL, 'the display name of the source');
$this->addArgument('description', InputArgument::OPTIONAL, 'the description of the source');
$this->addOption('bias', 'b', InputArgument::OPTIONAL, 'bias of the source', Bias::NEUTRAL->value);
$this->addOption('reliability', 'r', InputArgument::OPTIONAL, 'reliability of the source', Reliability::AVERAGE->value);
$this->addOption('transparency', 't', InputArgument::OPTIONAL, 'transparency of the source', Transparency::MEDIUM->value);
}
#[\Override]
protected function interact(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
$this->io->title('Create a new data source');
$this->askArgument($input, 'source');
$this->askArgument($input, 'displayName');
$this->askOption($input, 'bias');
$this->askOption($input, 'reliability');
$this->askOption($input, 'transparency');
}
#[\Override]
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
if (! $this->io->confirm('Do you want to continue?', false)) {
$this->io->warning('Process aborted');
return Command::FAILURE;
}
/** @var string $source */
$source = $input->getArgument('source');
/** @var string|null $displayName */
$displayName = $input->getArgument('displayName');
/** @var string|null $description */
$description = $input->getArgument('description');
$credibility = new Credibility(
bias: Bias::from($input->getOption('bias')),
reliability: Reliability::from($input->getOption('reliability')),
transparency: Transparency::from($input->getOption('transparency')),
);
$this->commandBus->handle(new CreateSource($source, $credibility, $displayName, $description));
$this->io->success('Source add successfully');
return Command::SUCCESS;
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Presentation\Console;
use Basango\Aggregator\Application\UseCase\Command\DeleteArticles;
use Basango\SharedKernel\Application\Messaging\CommandBus;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:delete-articles',
description: 'remove all articles from the database by source',
)]
class DeleteArticlesConsole extends Command
{
private SymfonyStyle $io;
public function __construct(
private readonly CommandBus $commandBus
) {
parent::__construct();
}
#[\Override]
protected function configure(): void
{
$this->addArgument('source', InputArgument::REQUIRED, 'the website source to crawle');
$this->addOption('category', null, InputOption::VALUE_OPTIONAL, 'the category to crawle');
}
#[\Override]
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var string $source */
$source = $input->getArgument('source');
/** @var string|null $category */
$category = $input->getOption('category');
if (
$this->io->confirm('Delete all articles ?', false) &&
$this->io->confirm('Are you sure ?', false)
) {
$confirmation = $this->io->askQuestion(new Question('Specify the source to confirm : '));
if ($confirmation === $source) {
/** @var int $count */
$count = $this->commandBus->handle(new DeleteArticles($source, $category));
$this->io->success(sprintf('%d articles from %s removed', $count, $source));
} else {
$this->io->warning('Source does not match, aborting !');
}
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Presentation\Console;
use Basango\Aggregator\Application\UseCase\Command\ExportArticles;
use Basango\SharedKernel\Application\Messaging\CommandBus;
use Basango\SharedKernel\Domain\Model\ValueObject\DateRange;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:export-articles',
description: 'export crawled news website',
)]
final class ExportArticlesConsole extends Command
{
private SymfonyStyle $io;
public function __construct(
private readonly CommandBus $commandBus
) {
parent::__construct();
}
#[\Override]
protected function configure(): void
{
$this->addArgument('source', InputArgument::OPTIONAL, 'the website source to crawle');
$this->addOption('date', null, InputOption::VALUE_OPTIONAL, 'Date interval to crawle', null);
}
#[\Override]
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var string|null $source */
$source = $input->getArgument('source');
/** @var string|null $date */
$date = $input->getOption('date');
$confirmation = $this->io->confirm('This can take a while, would like to continue ?', false);
if ($confirmation) {
$this->commandBus->handle(new ExportArticles(
source: $source,
date: $date !== null ? DateRange::from($date) : null
));
}
$this->io->success('articles exported successfully');
return Command::SUCCESS;
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Presentation\Console;
use Basango\Aggregator\Application\ReadModel\SourceStatistics;
use Basango\Aggregator\Application\ReadModel\SourceStatisticsList;
use Basango\Aggregator\Application\UseCase\Query\GetSourceStatisticsList;
use Basango\SharedKernel\Application\Messaging\QueryBus;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Stopwatch\Stopwatch;
#[AsCommand(
name: 'app:stats',
description: 'show stats about the articles in the database',
)]
class GetSourceStatisticsListConsole extends Command
{
private SymfonyStyle $io;
public function __construct(
private readonly QueryBus $queryBus
) {
parent::__construct();
}
#[\Override]
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int
{
/** @var SourceStatisticsList $stats */
$stats = $this->queryBus->handle(new GetSourceStatisticsList());
$stopWatch = new Stopwatch(true);
$stopWatch->start('app:stats');
$this->io->table(
['Source', 'Articles', 'Metadata', 'CrawledAt'],
array_map(
fn (SourceStatistics $source): array => [
$source->name,
number_format($source->articlesCount, decimal_separator: '.', thousands_separator: ','),
number_format($source->metadataAvailable, decimal_separator: '.', thousands_separator: ','),
$source->crawledAt?->format('Y-m-d H:i:s') ?? 'Never',
],
$stats->items
)
);
$this->io->text((string) $stopWatch->stop('app:stats'));
return Command::SUCCESS;
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Presentation\Web\Controller;
use Basango\Aggregator\Application\UseCase\Command\CreateArticle;
use Basango\Aggregator\Domain\Model\ValueObject\Link;
use Basango\Aggregator\Presentation\WriteModel\AddArticleModel;
use Basango\SharedKernel\Presentation\Web\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
/**
* Class AddArticleController.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class AddArticleController extends AbstractController
{
public function __construct(
#[Autowire(env: 'BASANGO_CRAWLER_TOKEN')] private readonly string $token
) {
}
#[Route(
path: '/api/aggregator/articles',
name: 'aggregator_add_article',
requirements: [
'token' => Requirement::ASCII_SLUG,
],
methods: ['POST']
)]
public function __invoke(
#[MapQueryParameter] string $token,
#[MapRequestPayload] AddArticleModel $model
): JsonResponse {
if ($token !== $this->token) {
throw $this->createAccessDeniedException();
}
$this->handleCommand(new CreateArticle(
$model->title,
Link::from($model->link),
$model->categories,
$model->body,
$model->source,
$model->timestamp,
$model->metadata,
$model->tokenStatistics
));
return new JsonResponse(status: Response::HTTP_CREATED);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Basango\Aggregator\Presentation\WriteModel;
use Basango\Aggregator\Domain\Model\ValueObject\OpenGraph;
use Basango\Aggregator\Domain\Model\ValueObject\TokenStatistics;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Class AddArticleModel.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class AddArticleModel
{
#[Assert\NotBlank]
public string $title;
#[Assert\NotBlank]
public string $link;
#[Assert\NotBlank]
public string $body;
#[Assert\NotBlank]
public string $source;
#[Assert\NotBlank]
public int $timestamp;
public array $categories = [];
public ?OpenGraph $metadata = null;
public ?TokenStatistics $tokenStatistics = null;
}