Initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Asset;
|
||||
|
||||
/**
|
||||
* Enum AssetType.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
enum AssetType
|
||||
{
|
||||
case SOURCE_PROFILE_IMAGE;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Asset;
|
||||
|
||||
/**
|
||||
* Class AssetUrlProvider.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface AssetUrlProvider
|
||||
{
|
||||
public function getUrl(string $id, AssetType $type): ?string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Mailing;
|
||||
|
||||
use App\SharedKernel\Domain\Model\ValueObject\EmailAddress;
|
||||
|
||||
/**
|
||||
* Interface EmailDefinition.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface EmailDefinition
|
||||
{
|
||||
public function recipient(): EmailAddress;
|
||||
|
||||
public function subject(): string;
|
||||
|
||||
public function subjectVariables(): array;
|
||||
|
||||
public function template(): string;
|
||||
|
||||
public function templateVariables(): array;
|
||||
|
||||
public function locale(): string;
|
||||
|
||||
public function getDomain(): string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Mailing;
|
||||
|
||||
/**
|
||||
* Interface Mailer.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface Mailer
|
||||
{
|
||||
public function send(EmailDefinition $email): void;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Messaging;
|
||||
|
||||
/**
|
||||
* Interface AsyncMessage.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface AsyncMessage
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Messaging;
|
||||
|
||||
/**
|
||||
* Interface CommandBus.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface CommandBus
|
||||
{
|
||||
public function handle(object $message): mixed;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Messaging;
|
||||
|
||||
/**
|
||||
* Interface CommandHandler.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface CommandHandler
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Messaging;
|
||||
|
||||
/**
|
||||
* Interface MessageBus.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface MessageBus
|
||||
{
|
||||
public function dispatch(AsyncMessage $message): void;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Messaging;
|
||||
|
||||
/**
|
||||
* Interface MessageHandler.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface MessageHandler
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Messaging;
|
||||
|
||||
/**
|
||||
* Interface QueryBus.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface QueryBus
|
||||
{
|
||||
public function handle(object $message): mixed;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Application\Messaging;
|
||||
|
||||
/**
|
||||
* Interface QueryHandler.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface QueryHandler
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain;
|
||||
|
||||
/**
|
||||
* Class Application.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class Application
|
||||
{
|
||||
public string $name = 'DRC News Corpus';
|
||||
|
||||
public string $website = 'https://research.devscast.org/drc-news-corpus';
|
||||
|
||||
public string $emailAddress = 'contact@devscast.tech';
|
||||
|
||||
public string $infoAddress = 'contact@devscast.tech';
|
||||
|
||||
public string $emailName = 'DRC News Corpus';
|
||||
|
||||
public string $legalName = 'Devscast Software SàSu';
|
||||
|
||||
public string $legalRegistrationCode = '';
|
||||
|
||||
public string $legalVatCode = '';
|
||||
|
||||
public string $legalAddress = '10465, Avenue Lac kipopo, Lubumbashi, Haut-Katanga, RDC';
|
||||
|
||||
public string $legalPhone = '+243892530482';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain;
|
||||
|
||||
use App\SharedKernel\Domain\Exception\InvalidArgument;
|
||||
|
||||
/**
|
||||
* Class Assert.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class Assert extends \Webmozart\Assert\Assert
|
||||
{
|
||||
#[\Override]
|
||||
protected static function reportInvalidArgument($message): void
|
||||
{
|
||||
throw new InvalidArgument($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\DataTransfert;
|
||||
|
||||
/**
|
||||
* Interface DataExporter.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface DataExporter
|
||||
{
|
||||
public function export(iterable $data, TransfertSetting $setting = new TransfertSetting()): \SplFileObject;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\DataTransfert;
|
||||
|
||||
/**
|
||||
* Class DataImporter.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface DataImporter
|
||||
{
|
||||
public function import(\SplFileObject $file, TransfertSetting $setting = new TransfertSetting()): iterable;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\DataTransfert;
|
||||
|
||||
use App\SharedKernel\Domain\Assert;
|
||||
use BackedEnum as T;
|
||||
use Symfony\Component\Uid\UuidV7;
|
||||
|
||||
/**
|
||||
* Class DataMapping.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
abstract class DataMapping
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function uuid(array $data, string $key): UuidV7
|
||||
{
|
||||
Assert::keyExists($data, $key);
|
||||
return UuidV7::fromString($data[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T of \BackedEnum
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
* @param class-string<T> $class
|
||||
* @phpstan-return T
|
||||
*/
|
||||
public static function enum(array $data, string $key, string $class): \BackedEnum
|
||||
{
|
||||
Assert::keyExists($data, $key);
|
||||
return $class::from($data[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T of \BackedEnum
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
* @param class-string<T> $class
|
||||
* @phpstan-return T
|
||||
*/
|
||||
public static function nullableEnum(array $data, string $key, string $class): ?\BackedEnum
|
||||
{
|
||||
Assert::keyExists($data, $key);
|
||||
return $class::tryFrom($data[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function string(array $data, string $key): string
|
||||
{
|
||||
if (! isset($data[$key]) || $data[$key] === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return strval($data[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function nullableString(array $data, string $key): ?string
|
||||
{
|
||||
if (! isset($data[$key]) || $data[$key] === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strval($data[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function boolean(array $data, string $key): bool
|
||||
{
|
||||
return isset($data[$key]) && (bool) $data[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function nullableBoolean(array $data, string $key): ?bool
|
||||
{
|
||||
if (! isset($data[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (bool) $data[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function integer(array $data, string $key): int
|
||||
{
|
||||
return isset($data[$key]) ? (int) $data[$key] : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function nullableInteger(array $data, string $key): ?int
|
||||
{
|
||||
if (! isset($data[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $data[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function float(array $data, string $key): float
|
||||
{
|
||||
return isset($data[$key]) ? (float) $data[$key] : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function nullableFloat(array $data, string $key): ?float
|
||||
{
|
||||
if (! isset($data[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $data[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function datetime(array $data, string $key, string $format = 'Y-m-d H:i:s'): \DateTimeImmutable
|
||||
{
|
||||
Assert::keyExists($data, $key);
|
||||
$datetime = \DateTimeImmutable::createFromFormat($format, $data[$key]);
|
||||
|
||||
if ($datetime === false) {
|
||||
throw new \InvalidArgumentException('Invalid datetime format');
|
||||
}
|
||||
|
||||
return $datetime;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @param non-empty-string $key
|
||||
*/
|
||||
public static function nullableDatetime(array $data, string $key, string $format = 'Y-m-d H:i:s'): ?\DateTimeImmutable
|
||||
{
|
||||
if (! isset($data[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$datetime = \DateTimeImmutable::createFromFormat($format, $data[$key]);
|
||||
|
||||
if ($datetime === false) {
|
||||
throw new \InvalidArgumentException('Invalid datetime format');
|
||||
}
|
||||
|
||||
return $datetime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\DataTransfert;
|
||||
|
||||
/**
|
||||
* Class TransfertSetting.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class TransfertSetting
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $filename = null,
|
||||
public ?string $type = null,
|
||||
public string $format = 'csv'
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\EventDispatcher;
|
||||
|
||||
/**
|
||||
* Interface EventDispatcher.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface EventDispatcher
|
||||
{
|
||||
/**
|
||||
* @param array<int, object> $events
|
||||
*/
|
||||
public function dispatch(array $events): void;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\EventDispatcher;
|
||||
|
||||
/**
|
||||
* Trait EventEmitterTrait.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
trait EventEmitterTrait
|
||||
{
|
||||
/**
|
||||
* @var array<int, object>
|
||||
*/
|
||||
private array $emittedEvents = [];
|
||||
|
||||
public function emitEvent(object $event): void
|
||||
{
|
||||
$this->emittedEvents[] = $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function releaseEvents(): array
|
||||
{
|
||||
return $this->emittedEvents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\EventListener;
|
||||
|
||||
/**
|
||||
* Interface EventListener.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface EventListener
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Class InvalidArgument.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class InvalidArgument extends \RuntimeException implements UserFacingError
|
||||
{
|
||||
#[\Override]
|
||||
public function translationId(): string
|
||||
{
|
||||
return 'shared_kernel.exceptions.invalid_argument';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function translationParameters(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function translationDomain(): string
|
||||
{
|
||||
return 'messages';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Class InvalidEmailAddress.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class InvalidEmailAddress extends \InvalidArgumentException implements UserFacingError
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $email
|
||||
) {
|
||||
parent::__construct(sprintf('%s : Invalid email address provided', $this->email));
|
||||
}
|
||||
|
||||
public static function withValue(string $value): self
|
||||
{
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function translationId(): string
|
||||
{
|
||||
return 'shared_kernel.exceptions.invalid_email_address';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function translationParameters(): array
|
||||
{
|
||||
return [
|
||||
'%email%' => $this->email,
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function translationDomain(): string
|
||||
{
|
||||
return 'messages';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Exception;
|
||||
|
||||
/**
|
||||
* Interface UserFacingError.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface UserFacingError extends \Throwable
|
||||
{
|
||||
public function translationId(): string;
|
||||
|
||||
public function translationParameters(): array;
|
||||
|
||||
public function translationDomain(): string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\Collection;
|
||||
|
||||
use Doctrine\Common\Collections\Collection as DoctrineCollection;
|
||||
|
||||
/**
|
||||
* Interface Collection.
|
||||
*
|
||||
* @phpstan-template TKey of array-key
|
||||
* @phpstan-template T
|
||||
* @phpstan-extends DoctrineCollection<TKey, T>
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface Collection extends DoctrineCollection
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\Collection;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection as DoctrineArrayCollection;
|
||||
|
||||
/**
|
||||
* Class DataCollection.
|
||||
*
|
||||
* @phpstan-template TKey of array-key
|
||||
* @phpstan-template T
|
||||
* @phpstan-extends DoctrineArrayCollection<int, T>
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class DataCollection extends DoctrineArrayCollection
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\Pagination;
|
||||
|
||||
use App\SharedKernel\Domain\Assert;
|
||||
|
||||
/**
|
||||
* Class Page.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class Page
|
||||
{
|
||||
public const int DEFAULT_PAGE = 1;
|
||||
|
||||
public const int DEFAULT_LIMIT = 5;
|
||||
|
||||
public const int MAX_LIMIT = 100;
|
||||
|
||||
public function __construct(
|
||||
public int $page = self::DEFAULT_PAGE,
|
||||
public int $limit = self::DEFAULT_LIMIT,
|
||||
public ?string $cursor = null,
|
||||
public int $offset = 0,
|
||||
) {
|
||||
Assert::greaterThanEq($this->page, self::DEFAULT_PAGE);
|
||||
Assert::greaterThanEq($this->limit, self::DEFAULT_LIMIT);
|
||||
Assert::lessThanEq($this->limit, self::MAX_LIMIT);
|
||||
|
||||
$this->offset = intval(($this->page - 1) * $this->limit);
|
||||
}
|
||||
|
||||
public function next(): self
|
||||
{
|
||||
return new self($this->page + 1, $this->limit);
|
||||
}
|
||||
|
||||
public function previous(): self
|
||||
{
|
||||
if ($this->page === self::DEFAULT_PAGE) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($this->page - 1, $this->limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\Pagination;
|
||||
|
||||
use App\SharedKernel\Domain\DataTransfert\DataMapping;
|
||||
use Symfony\Component\Uid\UuidV7;
|
||||
|
||||
/**
|
||||
* Class PaginationCursor.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class PaginationCursor
|
||||
{
|
||||
public function __construct(
|
||||
public UuidV7 $id,
|
||||
public \DateTimeImmutable $date,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new PaginationCursor from a DateTimeImmutable and a UuidV7.
|
||||
* @throws \JsonException When JSON encoding fails
|
||||
*/
|
||||
public static function encode(array $item, PaginatorKeyset $keyset): string
|
||||
{
|
||||
$id = DataMapping::uuid($item, $keyset->id)->toString();
|
||||
|
||||
if ($keyset->date !== null) {
|
||||
$date = DataMapping::dateTime($item, $keyset->date)->format('Y-m-d H:i:s');
|
||||
|
||||
return base64_encode(
|
||||
json_encode([
|
||||
'date' => $date,
|
||||
'id' => $id,
|
||||
], JSON_THROW_ON_ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
return base64_encode(
|
||||
json_encode([
|
||||
'id' => $id,
|
||||
], JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a cursor string into a PaginationCursor object.
|
||||
* Returns null if the cursor is invalid or cannot be decoded.
|
||||
*/
|
||||
public static function decode(?string $cursor): ?self
|
||||
{
|
||||
if ($cursor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode(base64_decode($cursor), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
if (! is_array($data) || ! isset($data['date'], $data['id'])) {
|
||||
throw new \InvalidArgumentException('Invalid cursor format');
|
||||
}
|
||||
|
||||
return new self(
|
||||
id: UuidV7::fromString($data['id']),
|
||||
date: new \DateTimeImmutable($data['date'])
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\Pagination;
|
||||
|
||||
/**
|
||||
* Class PaginationInfo.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class PaginationInfo
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $current,
|
||||
public readonly int $limit,
|
||||
public ?string $cursor = null,
|
||||
public bool $hasNext = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function from(Page $page): self
|
||||
{
|
||||
return new self($page->page, $page->limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\Pagination;
|
||||
|
||||
/**
|
||||
* Class PaginatorKeyset.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class PaginatorKeyset
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $id
|
||||
* @param non-empty-string|null $date
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public ?string $date = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\ValueObject;
|
||||
|
||||
use App\SharedKernel\Domain\Assert;
|
||||
use DateTime;
|
||||
|
||||
/**
|
||||
* Class DateRange.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class DateRange implements \Stringable
|
||||
{
|
||||
private function __construct(
|
||||
public int $start,
|
||||
public int $end
|
||||
) {
|
||||
Assert::notEq($this->start, 0);
|
||||
Assert::notEq($this->end, 0);
|
||||
Assert::greaterThanEq($end, $start);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf('%d:%d', $this->start, $this->end);
|
||||
}
|
||||
|
||||
public static function from(string $interval, string $format = 'Y-m-d', string $separator = ':'): self
|
||||
{
|
||||
if ($separator === '') {
|
||||
throw new \InvalidArgumentException('Separator cannot be empty');
|
||||
}
|
||||
|
||||
[$startDate, $endDate] = explode($separator, $interval);
|
||||
|
||||
/** @var DateTime $start */
|
||||
$start = DateTime::createFromFormat($format, $startDate);
|
||||
|
||||
/** @var DateTime $end */
|
||||
$end = DateTime::createFromFormat($format, $endDate);
|
||||
|
||||
return new self((int) $start->format('U'), (int) $end->format('U'));
|
||||
}
|
||||
|
||||
public static function backward(\DateTimeImmutable $date = new \DateTimeImmutable(), ?int $days = null): self
|
||||
{
|
||||
$days ??= 30;
|
||||
$start = $date->modify(sprintf('-%d days', $days));
|
||||
$end = $date->modify('+1 days');
|
||||
|
||||
return new self((int) $start->format('U'), (int) $end->format('U'));
|
||||
}
|
||||
|
||||
public static function forward(\DateTimeImmutable $date): self
|
||||
{
|
||||
$start = $date;
|
||||
$end = new \DateTimeImmutable('now')->modify('+1 days');
|
||||
|
||||
return new self((int) $start->format('U'), (int) $end->format('U'));
|
||||
}
|
||||
|
||||
public function format(string $format = 'Y-m-d'): string
|
||||
{
|
||||
return sprintf('%s:%s', date($format, $this->start), date($format, $this->end));
|
||||
}
|
||||
|
||||
public function inRange(int $timestamp): bool
|
||||
{
|
||||
return $timestamp >= $this->start && $timestamp <= $this->end;
|
||||
}
|
||||
|
||||
public function outRange(int $timestamp): bool
|
||||
{
|
||||
return $timestamp < $this->start || $timestamp > $this->end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\ValueObject;
|
||||
|
||||
use App\SharedKernel\Domain\Assert;
|
||||
use App\SharedKernel\Domain\Exception\InvalidEmailAddress;
|
||||
|
||||
/**
|
||||
* Class EmailAddress.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class EmailAddress implements \Stringable, \JsonSerializable
|
||||
{
|
||||
public string $value;
|
||||
|
||||
public function __construct(string $value)
|
||||
{
|
||||
try {
|
||||
Assert::notEmpty($value);
|
||||
Assert::email($value);
|
||||
} catch (\Throwable) {
|
||||
throw InvalidEmailAddress::withValue($value);
|
||||
}
|
||||
|
||||
$this->value = $value;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidEmailAddress
|
||||
*/
|
||||
public static function from(string $value): self
|
||||
{
|
||||
return new self($value);
|
||||
}
|
||||
|
||||
public function provider(): string
|
||||
{
|
||||
return substr($this->value, strpos($this->value, '@') + 1);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\ValueObject;
|
||||
|
||||
/**
|
||||
* Enum SortDirection.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
enum SortDirection: string
|
||||
{
|
||||
case ASC = 'asc';
|
||||
case DESC = 'desc';
|
||||
|
||||
public function opposite(): self
|
||||
{
|
||||
return match ($this) {
|
||||
self::ASC => self::DESC,
|
||||
self::DESC => self::ASC,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\ValueObject\Tracking;
|
||||
|
||||
/**
|
||||
* Class ClientProfile.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class ClientProfile
|
||||
{
|
||||
public function __construct(
|
||||
#[\SensitiveParameter] public ?string $userIp = null,
|
||||
public ?string $userAgent = null,
|
||||
public array $hints = []
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\ValueObject\Tracking;
|
||||
|
||||
/**
|
||||
* Class Device.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class Device
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $operatingSystem = null,
|
||||
public ?string $client = null,
|
||||
public ?string $device = null,
|
||||
public bool $isBot = false,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Model\ValueObject\Tracking;
|
||||
|
||||
use App\SharedKernel\Domain\Assert;
|
||||
|
||||
/**
|
||||
* Class GeoLocation.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class GeoLocation
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $country = null,
|
||||
public ?string $city = null,
|
||||
public ?string $timeZone = null,
|
||||
public ?float $longitude = null,
|
||||
public ?float $latitude = null,
|
||||
public ?int $accuracyRadius = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function from(array $data): self
|
||||
{
|
||||
Assert::keyExists($data, 'country');
|
||||
Assert::keyExists($data, 'city');
|
||||
Assert::keyExists($data, 'time_zone');
|
||||
Assert::keyExists($data, 'longitude');
|
||||
Assert::keyExists($data, 'latitude');
|
||||
Assert::keyExists($data, 'accuracy_radius');
|
||||
|
||||
return new self(
|
||||
country: $data['country'] ?? null,
|
||||
city: $data['city'] ?? null,
|
||||
timeZone: $data['time_zone'] ?? null,
|
||||
longitude: $data['longitude'] ?? null,
|
||||
latitude: $data['latitude'] ?? null,
|
||||
accuracyRadius: $data['accuracy_radius'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Domain\Tracking;
|
||||
|
||||
use App\SharedKernel\Domain\Model\ValueObject\Tracking\ClientProfile;
|
||||
use App\SharedKernel\Domain\Model\ValueObject\Tracking\Device;
|
||||
use App\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation;
|
||||
|
||||
/**
|
||||
* Class ClientProfiler.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
interface ClientProfiler
|
||||
{
|
||||
public function detect(ClientProfile $profile): Device;
|
||||
|
||||
public function locate(ClientProfile $profile): GeoLocation;
|
||||
|
||||
public function locateCountry(ClientProfile $profile): ?string;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Framework\Symfony\EventDispatcher;
|
||||
|
||||
use App\SharedKernel\Domain\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
* Class SymfonyEventDispatcher.
|
||||
*
|
||||
* @see https://symfony.com/doc/current/components/event_dispatcher.html
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class SymfonyEventDispatcher implements EventDispatcher
|
||||
{
|
||||
public function __construct(
|
||||
private EventDispatcherInterface $eventDispatcher
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, object> $events
|
||||
*/
|
||||
#[\Override]
|
||||
public function dispatch(array $events): void
|
||||
{
|
||||
foreach ($events as $event) {
|
||||
$this->eventDispatcher->dispatch($event, $event::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Framework\Symfony;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
/**
|
||||
* Class Kernel.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Logging;
|
||||
|
||||
use Monolog\Formatter\FormatterInterface;
|
||||
use Monolog\Formatter\NormalizerFormatter as MonologNormalizerFormatter;
|
||||
use Monolog\Utils;
|
||||
|
||||
/**
|
||||
* Class NormalizerFormatter.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
class NormalizerFormatter extends MonologNormalizerFormatter implements FormatterInterface
|
||||
{
|
||||
public const string SIMPLE_DATE = 'Y-m-d\TH:i:sP';
|
||||
|
||||
protected string $dateFormat;
|
||||
|
||||
protected int $maxNormalizeDepth = 9;
|
||||
|
||||
protected int $maxNormalizeItemCount = 1000;
|
||||
|
||||
protected string $basePath = '';
|
||||
|
||||
private int $jsonEncodeOptions = JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_PRETTY_PRINT;
|
||||
|
||||
/**
|
||||
* Setting a base path will hide the base path from exception and stack trace file names to shorten them
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
#[\Override]
|
||||
public function setBasePath(string $path = ''): self
|
||||
{
|
||||
if ($path !== '') {
|
||||
$path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
}
|
||||
|
||||
$this->basePath = $path;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the JSON representation of a value
|
||||
*
|
||||
* @param mixed $data
|
||||
* @return string if encoding fails and ignoreErrors is true 'null' is returned
|
||||
* @throws \RuntimeException if encoding fails and errors are not ignored
|
||||
*/
|
||||
#[\Override]
|
||||
protected function toJson($data, bool $ignoreErrors = false): string
|
||||
{
|
||||
$json = Utils::jsonEncode($data, $this->jsonEncodeOptions, $ignoreErrors);
|
||||
return <<< JSON
|
||||
```json
|
||||
{$json}
|
||||
```
|
||||
JSON;
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Logging;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
|
||||
/**
|
||||
* Class TelegramFormatter.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
class TelegramFormatter extends NormalizerFormatter
|
||||
{
|
||||
public const int BATCH_MODE_JSON = 1;
|
||||
|
||||
public const int BATCH_MODE_NEWLINES = 2;
|
||||
|
||||
/**
|
||||
* @param self::BATCH_MODE_* $batchMode
|
||||
*
|
||||
* @throws \RuntimeException If the function json_encode does not exist
|
||||
*/
|
||||
public function __construct(
|
||||
protected int $batchMode = self::BATCH_MODE_JSON,
|
||||
protected bool $appendNewline = true,
|
||||
protected bool $ignoreEmptyContextAndExtra = false,
|
||||
protected bool $includeStacktraces = false,
|
||||
string $basePath = ''
|
||||
) {
|
||||
$this->basePath = $basePath;
|
||||
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch mode option configures the formatting style for
|
||||
* multiple records. By default, multiple records will be
|
||||
* formatted as a JSON-encoded array. However, for
|
||||
* compatibility with some API endpoints, alternative styles
|
||||
* are available.
|
||||
*/
|
||||
public function getBatchMode(): int
|
||||
{
|
||||
return $this->batchMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if newlines are appended to every formatted record
|
||||
*/
|
||||
public function isAppendingNewlines(): bool
|
||||
{
|
||||
return $this->appendNewline;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function format(LogRecord $record): string
|
||||
{
|
||||
/** @var array<string, mixed> $normalized */
|
||||
$normalized = parent::format($record);
|
||||
|
||||
if (isset($normalized['context']) && $normalized['context'] === []) {
|
||||
unset($normalized['context']);
|
||||
}
|
||||
|
||||
if (isset($normalized['extra']) && $normalized['extra'] === []) {
|
||||
unset($normalized['extra']);
|
||||
}
|
||||
|
||||
return $this->toJson($normalized, true) . "\n\n";
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function formatBatch(array $records): string
|
||||
{
|
||||
return match ($this->batchMode) {
|
||||
static::BATCH_MODE_NEWLINES => $this->formatBatchNewlines($records),
|
||||
default => $this->formatBatchJson($records),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function includeStacktraces(bool $include = true): self
|
||||
{
|
||||
$this->includeStacktraces = $include;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a JSON-encoded array of records.
|
||||
*
|
||||
* @phpstan-param LogRecord[] $records
|
||||
*/
|
||||
protected function formatBatchJson(array $records): string
|
||||
{
|
||||
return $this->toJson($this->normalize($records), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use new lines to separate records instead of a
|
||||
* JSON-encoded array.
|
||||
*
|
||||
* @phpstan-param LogRecord[] $records
|
||||
*/
|
||||
protected function formatBatchNewlines(array $records): string
|
||||
{
|
||||
$oldNewline = $this->appendNewline;
|
||||
$this->appendNewline = false;
|
||||
$formatted = array_map(fn (LogRecord $record): string => $this->format($record), $records);
|
||||
$this->appendNewline = $oldNewline;
|
||||
|
||||
return implode("\n\n", $formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes given $data.
|
||||
*
|
||||
* @return array<array|bool|float|int|object|string|null>|bool|float|int|object|string|null
|
||||
*/
|
||||
#[\Override]
|
||||
protected function normalize(mixed $data, int $depth = 0): array|bool|float|int|object|string|null
|
||||
{
|
||||
if ($depth > $this->maxNormalizeDepth) {
|
||||
return 'Over ' . $this->maxNormalizeDepth . ' levels deep, aborting normalization';
|
||||
}
|
||||
|
||||
if (\is_array($data)) {
|
||||
$normalized = [];
|
||||
|
||||
$count = 1;
|
||||
foreach ($data as $key => $value) {
|
||||
if ($count++ > $this->maxNormalizeItemCount) {
|
||||
$normalized['...'] = 'Over ' . $this->maxNormalizeItemCount . ' items (' . \count($data) . ' total), aborting normalization';
|
||||
break;
|
||||
}
|
||||
|
||||
$normalized[$key] = $this->normalize($value, $depth + 1);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
if (\is_object($data)) {
|
||||
if ($data instanceof \DateTimeInterface) {
|
||||
return $this->formatDate($data);
|
||||
}
|
||||
|
||||
if ($data instanceof \Throwable) {
|
||||
/** @var array|float|object|bool|int|string|null $throwable */
|
||||
$throwable = $this->normalizeException($data, $depth);
|
||||
return $throwable;
|
||||
}
|
||||
|
||||
// if the object has specific json serializability we want to make sure we skip the __toString treatment below
|
||||
if ($data instanceof \JsonSerializable) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ($data instanceof \Stringable) {
|
||||
return $data->__toString();
|
||||
}
|
||||
|
||||
if ($data::class === '__PHP_Incomplete_Class') {
|
||||
return new \ArrayObject($data);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
if (\is_resource($data)) {
|
||||
return parent::normalize($data);
|
||||
}
|
||||
|
||||
/** @var array|float|object|bool|int|string|null $data */
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes given exception with or without its own stack trace based on
|
||||
* `includeStacktraces` property.
|
||||
*
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
protected function normalizeException(\Throwable $e, int $depth = 0): array|float|object|bool|int|string|null
|
||||
{
|
||||
$data = parent::normalizeException($e, $depth);
|
||||
if (! $this->includeStacktraces) {
|
||||
unset($data['trace']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Mailing;
|
||||
|
||||
use App\SharedKernel\Application\Mailing\EmailDefinition;
|
||||
use App\SharedKernel\Application\Mailing\Mailer;
|
||||
use App\SharedKernel\Domain\Application;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Class SymfonyMailer.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class SymfonyMailer implements Mailer
|
||||
{
|
||||
public function __construct(
|
||||
private MailerInterface $mailer,
|
||||
private TranslatorInterface $translator,
|
||||
private Application $application = new Application()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
#[\Override]
|
||||
public function send(EmailDefinition $email): void
|
||||
{
|
||||
$sender = new Address(
|
||||
$this->application->emailAddress,
|
||||
$this->application->emailName
|
||||
);
|
||||
|
||||
$htmlTemplate = sprintf('emails/%s.html.twig', $email->template());
|
||||
$txtTemplate = sprintf('emails/%s.txt.twig', $email->template());
|
||||
|
||||
$message = new TemplatedEmail()
|
||||
->from($sender)
|
||||
->to($email->recipient()->value)
|
||||
->subject(
|
||||
$this->translator->trans(
|
||||
$email->subject(),
|
||||
$email->subjectVariables(),
|
||||
$email->getDomain(),
|
||||
$email->locale()
|
||||
)
|
||||
)
|
||||
->htmlTemplate($htmlTemplate)
|
||||
->textTemplate($txtTemplate)
|
||||
->context(array_merge(
|
||||
$email->templateVariables(),
|
||||
[
|
||||
'application' => new Application(),
|
||||
'locale' => $email->locale(),
|
||||
'domain' => $email->getDomain(),
|
||||
]
|
||||
))
|
||||
;
|
||||
|
||||
$this->mailer->send($message);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Messaging;
|
||||
|
||||
use App\SharedKernel\Application\Messaging\CommandBus;
|
||||
use Symfony\Component\Messenger\Exception\HandlerFailedException;
|
||||
use Symfony\Component\Messenger\HandleTrait;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Class MessengerCommandBus.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class MessengerCommandBus implements CommandBus
|
||||
{
|
||||
use HandleTrait {
|
||||
HandleTrait::handle as messengerHandle;
|
||||
}
|
||||
|
||||
public function __construct(MessageBusInterface $commandBus)
|
||||
{
|
||||
$this->messageBus = $commandBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Throwable
|
||||
*/
|
||||
#[\Override]
|
||||
public function handle(object $command): mixed
|
||||
{
|
||||
try {
|
||||
return $this->messengerHandle($command);
|
||||
} catch (HandlerFailedException $e) {
|
||||
while ($e instanceof HandlerFailedException) {
|
||||
/** @var \Throwable $e */
|
||||
$e = $e->getPrevious();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Messaging;
|
||||
|
||||
use App\SharedKernel\Application\Messaging\AsyncMessage;
|
||||
use App\SharedKernel\Application\Messaging\MessageBus;
|
||||
use Symfony\Component\Messenger\Exception\ExceptionInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Class MessengerMessageBus.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class MessengerMessageBus implements MessageBus
|
||||
{
|
||||
public function __construct(
|
||||
private MessageBusInterface $messageBus
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ExceptionInterface
|
||||
*/
|
||||
#[\Override]
|
||||
public function dispatch(AsyncMessage $message): void
|
||||
{
|
||||
$this->messageBus->dispatch($message);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Framework\Symfony\Messaging;
|
||||
|
||||
use App\SharedKernel\Application\Messaging\QueryBus;
|
||||
use Symfony\Component\Messenger\Exception\HandlerFailedException;
|
||||
use Symfony\Component\Messenger\HandleTrait;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* Class MessengerQueryBus.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class MessengerQueryBus implements QueryBus
|
||||
{
|
||||
use HandleTrait {
|
||||
HandleTrait::handle as messengerHandle;
|
||||
}
|
||||
|
||||
public function __construct(MessageBusInterface $queryBus)
|
||||
{
|
||||
$this->messageBus = $queryBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Throwable
|
||||
*/
|
||||
#[\Override]
|
||||
public function handle(object $message): mixed
|
||||
{
|
||||
try {
|
||||
return $this->messengerHandle($message);
|
||||
} catch (HandlerFailedException $e) {
|
||||
while ($e instanceof HandlerFailedException) {
|
||||
/** @var \Throwable $e */
|
||||
$e = $e->getPrevious();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Features;
|
||||
|
||||
use App\SharedKernel\Domain\Model\Pagination\Page;
|
||||
use App\SharedKernel\Domain\Model\Pagination\PaginationCursor;
|
||||
use App\SharedKernel\Domain\Model\Pagination\PaginationInfo;
|
||||
use App\SharedKernel\Domain\Model\Pagination\PaginatorKeyset;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use Doctrine\DBAL\Query\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Provides methods for generating and applying pagination to datasets and query builders.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
trait PaginationQuery
|
||||
{
|
||||
public function createPaginationInfo(array $data, Page $page, PaginatorKeyset $keyset): PaginationInfo
|
||||
{
|
||||
$paginationInfo = PaginationInfo::from($page);
|
||||
if ($data === []) {
|
||||
return $paginationInfo;
|
||||
}
|
||||
|
||||
$paginationInfo->cursor = PaginationCursor::encode(array_pop($data), $keyset);
|
||||
$paginationInfo->hasNext = count($data) > $page->limit;
|
||||
|
||||
return $paginationInfo;
|
||||
}
|
||||
|
||||
public function applyCursorPagination(QueryBuilder $qb, Page $page, PaginatorKeyset $keyset): QueryBuilder
|
||||
{
|
||||
$cursor = PaginationCursor::decode($page->cursor);
|
||||
if (! $cursor instanceof PaginationCursor) {
|
||||
return $this->applyOffsetPagination($qb, $page);
|
||||
}
|
||||
|
||||
if ($keyset->date === null) {
|
||||
$qb
|
||||
->andWhere(sprintf('%s <= :cursorLastId', $keyset->id))
|
||||
->setParameter('cursorLastId', $cursor->id->toString(), ParameterType::BINARY);
|
||||
} else {
|
||||
$qb
|
||||
->andWhere(sprintf('(%s, %s) <= (:cursorLastDate, :cursorLastId)', $keyset->date, $keyset->id))
|
||||
->setParameter('cursorLastDate', $cursor->id->toBinary(), ParameterType::BINARY)
|
||||
->setParameter('cursorLastId', $cursor->date->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
return $qb->setMaxResults($page->limit + 1);
|
||||
}
|
||||
|
||||
public function applyOffsetPagination(QueryBuilder $qb, Page $page): QueryBuilder
|
||||
{
|
||||
return $qb
|
||||
->setFirstResult($page->offset)
|
||||
->setMaxResults($page->limit)
|
||||
;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL;
|
||||
|
||||
/**
|
||||
* Class NoResult.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class NoResult extends \RuntimeException
|
||||
{
|
||||
public static function forQuery(string $query, array $parameters, ?\Throwable $previous = null): self
|
||||
{
|
||||
return new self(
|
||||
sprintf('%s - Query "%s" (parameters: %s) produced no results', $previous?->getMessage(), $query, json_encode($parameters)),
|
||||
previous: $previous
|
||||
);
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Persistence\Doctrine\DBAL\Types;
|
||||
|
||||
use App\SharedKernel\Domain\Model\ValueObject\EmailAddress;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
use Doctrine\DBAL\Types\ConversionException;
|
||||
use Doctrine\DBAL\Types\Type;
|
||||
|
||||
/**
|
||||
* Class EmailType.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class EmailType extends Type
|
||||
{
|
||||
public const string NAME = 'email';
|
||||
|
||||
#[\Override]
|
||||
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
|
||||
{
|
||||
return $platform->getStringTypeDeclarationSQL([
|
||||
'length' => 255,
|
||||
]);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?EmailAddress
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! \is_string($value)) {
|
||||
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', EmailAddress::class]);
|
||||
}
|
||||
|
||||
try {
|
||||
return EmailAddress::from($value);
|
||||
} catch (\Throwable $e) {
|
||||
throw ConversionException::conversionFailed($value, $this->getName(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
|
||||
{
|
||||
if ($value instanceof EmailAddress) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! \is_string($value)) {
|
||||
throw ConversionException::conversionFailedInvalidType($value, $this->getName(), ['null', 'string', EmailAddress::class]);
|
||||
}
|
||||
|
||||
try {
|
||||
return (string) EmailAddress::from($value);
|
||||
} catch (\Throwable $e) {
|
||||
throw ConversionException::conversionFailed($value, $this->getName(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function getName(): string
|
||||
{
|
||||
return self::NAME;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Persistence\Filesystem\Asset;
|
||||
|
||||
use App\SharedKernel\Application\Asset\AssetType;
|
||||
use App\SharedKernel\Application\Asset\AssetUrlProvider as AssetUrlProviderInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Class AssetUrlProvider.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class AssetUrlProvider implements AssetUrlProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire(env: 'SERVER_ADDR')] public ?string $serverAddress = null,
|
||||
#[Autowire(env: 'SERVER_PORT')] public ?string $serverPort = null,
|
||||
#[Autowire(env: 'APP_ENV')] public string $env = 'dev'
|
||||
) {
|
||||
$this->serverAddress ??= $_SERVER['SERVER_ADDR'] ?? null;
|
||||
$this->serverPort ??= $_SERVER['SERVER_PORT'] ?? null;
|
||||
}
|
||||
|
||||
public function getUrl(string $id, AssetType $type): ?string
|
||||
{
|
||||
if ($this->serverAddress === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = match ($type) {
|
||||
AssetType::SOURCE_PROFILE_IMAGE => sprintf('/images/sources/%s.png', $id),
|
||||
};
|
||||
|
||||
$scheme = $this->env === 'prod' ? 'https' : 'http';
|
||||
return sprintf('%s://%s:%s/%s', $scheme, $this->serverAddress, $this->serverPort, $path);
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Persistence\Filesystem;
|
||||
|
||||
use App\SharedKernel\Domain\Assert;
|
||||
use App\SharedKernel\Domain\DataTransfert\DataExporter;
|
||||
use App\SharedKernel\Domain\DataTransfert\DataImporter;
|
||||
use App\SharedKernel\Domain\DataTransfert\TransfertSetting;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* Class DataTransfert.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class DataTransfert implements DataImporter, DataExporter
|
||||
{
|
||||
private const array SUPPORTED_FORMATS = ['csv'];
|
||||
|
||||
public function __construct(
|
||||
private SerializerInterface $serializer,
|
||||
private Filesystem $filesystem,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function export(iterable $data, TransfertSetting $setting = new TransfertSetting()): \SplFileObject
|
||||
{
|
||||
Assert::oneOf($setting->format, self::SUPPORTED_FORMATS);
|
||||
|
||||
$data = $this->serializer->serialize($data, $setting->format);
|
||||
$filename = $setting->filename ?? $this->filesystem->tempnam(sys_get_temp_dir(), 'export');
|
||||
$this->filesystem->dumpFile($filename, $data);
|
||||
|
||||
return new \SplFileObject($filename);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function import(\SplFileObject $file, TransfertSetting $setting = new TransfertSetting()): iterable
|
||||
{
|
||||
Assert::notNull($setting->type);
|
||||
Assert::oneOf($setting->format, self::SUPPORTED_FORMATS);
|
||||
|
||||
$data = $this->filesystem->readFile($file->getPathname());
|
||||
|
||||
return $this->serializer->deserialize($data, $setting->type, $setting->format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Infrastructure\Tracking;
|
||||
|
||||
use App\SharedKernel\Domain\Assert;
|
||||
use App\SharedKernel\Domain\Model\ValueObject\Tracking\ClientProfile;
|
||||
use App\SharedKernel\Domain\Model\ValueObject\Tracking\Device;
|
||||
use App\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation;
|
||||
use App\SharedKernel\Domain\Tracking\ClientProfiler as ClientProfilerInterface;
|
||||
use DeviceDetector\ClientHints;
|
||||
use DeviceDetector\DeviceDetector;
|
||||
use DeviceDetector\Parser\Client\Browser;
|
||||
use DeviceDetector\Parser\OperatingSystem;
|
||||
use GeoIp2\Database\Reader;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Class ClientProfiler.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final readonly class ClientProfiler implements ClientProfilerInterface
|
||||
{
|
||||
private const string GEOIP_CITY_DATABASE = 'geoip_city.mmdb';
|
||||
|
||||
private const string GEOIP_COUNTRY_DATABASE = 'geoip_country.mmdb';
|
||||
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
private LoggerInterface $logger
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function locate(ClientProfile $profile): GeoLocation
|
||||
{
|
||||
if ($this->shouldSkipIpLocalization($profile)) {
|
||||
return GeoLocation::empty();
|
||||
}
|
||||
|
||||
try {
|
||||
$database = sprintf('%s/%s', $this->projectDir, self::GEOIP_CITY_DATABASE);
|
||||
|
||||
Assert::notNull($profile->userIp);
|
||||
$data = new Reader($database)->city($profile->userIp);
|
||||
|
||||
return GeoLocation::from([
|
||||
'country' => $data->country->isoCode,
|
||||
'city' => $data->city->name,
|
||||
'time_zone' => $data->location->timeZone,
|
||||
'longitude' => $data->location->longitude,
|
||||
'latitude' => $data->location->latitude,
|
||||
'accuracy_radius' => $data->location->accuracyRadius,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Unable to fetch location from IP address', [
|
||||
'ip' => $profile->userIp,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return GeoLocation::empty();
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function locateCountry(ClientProfile $profile): ?string
|
||||
{
|
||||
if ($this->shouldSkipIpLocalization($profile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$database = sprintf('%s/%s', $this->projectDir, self::GEOIP_COUNTRY_DATABASE);
|
||||
|
||||
Assert::notNull($profile->userIp);
|
||||
$data = new Reader($database)->country($profile->userIp);
|
||||
|
||||
/** @var string|null $country */
|
||||
$country = $data->country->isoCode;
|
||||
|
||||
return $country;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Unable to fetch country from IP address', [
|
||||
'ip' => $profile->userIp,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function detect(ClientProfile $profile): Device
|
||||
{
|
||||
if ($profile->userAgent === null || $profile->hints === []) {
|
||||
return Device::empty();
|
||||
}
|
||||
|
||||
try {
|
||||
$detector = new DeviceDetector($profile->userAgent, ClientHints::factory($profile->hints));
|
||||
$detector->parse();
|
||||
|
||||
$osLabel = is_string($detector->getOs('name')) ? $detector->getOs('name') : '';
|
||||
$clientLabel = is_string($detector->getClient('name')) ? $detector->getClient('name') : '';
|
||||
|
||||
return new Device(
|
||||
operatingSystem: OperatingSystem::getOsFamily($osLabel),
|
||||
client: match (true) {
|
||||
$detector->isBrowser() => Browser::getBrowserFamily($clientLabel),
|
||||
default => $clientLabel
|
||||
},
|
||||
device: $detector->getDeviceName(),
|
||||
isBot: $detector->isBot(),
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Unable to detect device', [
|
||||
'user_agent' => $profile->userAgent,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return Device::empty();
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldSkipIpLocalization(ClientProfile $profile): bool
|
||||
{
|
||||
return \filter_var($profile->userIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)
|
||||
|| $profile->userIp === null
|
||||
|| IpUtils::isPrivateIp($profile->userIp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Presentation\Console;
|
||||
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
/**
|
||||
* Trait AskArgumentFeature.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
trait AskArgumentFeature
|
||||
{
|
||||
private function askArgument(InputInterface $input, string $name, bool $hidden = false): void
|
||||
{
|
||||
$value = \strval($input->getArgument($name));
|
||||
if ($value !== '') {
|
||||
$this->io->text(\sprintf(' > <info>%s</info>: %s', $name, $value));
|
||||
} else {
|
||||
$value = match ($hidden) {
|
||||
false => $this->io->ask(\strtoupper($name)),
|
||||
default => $this->io->askHidden(\strtoupper($name))
|
||||
};
|
||||
$input->setArgument($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
private function askOption(InputInterface $input, string $name): void
|
||||
{
|
||||
$value = \strval($input->getOption($name));
|
||||
if ($value !== '') {
|
||||
$this->io->text(\sprintf(' > <info>%s</info>: %s', $name, $value));
|
||||
} else {
|
||||
$value = $this->io->ask(\strtoupper($name));
|
||||
$input->setOption($name, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Presentation\Web\Controller;
|
||||
|
||||
use App\IdentityAndAccess\Infrastructure\Framework\Symfony\Security\SecurityUser;
|
||||
use App\SharedKernel\Application\Messaging\CommandBus;
|
||||
use App\SharedKernel\Application\Messaging\QueryBus;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SymfonyController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Class AbstractController.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
abstract class AbstractController extends SymfonyController
|
||||
{
|
||||
protected ?Response $response = null;
|
||||
|
||||
#[\Override]
|
||||
public static function getSubscribedServices(): array
|
||||
{
|
||||
$subscribedServices = parent::getSubscribedServices();
|
||||
|
||||
$subscribedServices[] = CommandBus::class;
|
||||
$subscribedServices[] = QueryBus::class;
|
||||
$subscribedServices[] = TranslatorInterface::class;
|
||||
$subscribedServices[] = LoggerInterface::class;
|
||||
$subscribedServices[] = SerializerInterface::class;
|
||||
|
||||
return $subscribedServices;
|
||||
}
|
||||
|
||||
public function getSecurityUser(): SecurityUser
|
||||
{
|
||||
/** @var SecurityUser|null $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
if ($user === null) {
|
||||
throw $this->createAccessDeniedException(
|
||||
'You must be authenticated to access this resource.'
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function serialize(mixed $data, string $format = 'json', array $context = []): string
|
||||
{
|
||||
/** @var SerializerInterface $serializer */
|
||||
$serializer = $this->container->get(SerializerInterface::class);
|
||||
return $serializer->serialize($data, $format, $context);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
return parent::render($view, $parameters, $response ?? $this->response);
|
||||
}
|
||||
|
||||
protected function handleCommand(object $command): mixed
|
||||
{
|
||||
/** @var CommandBus $commandBus */
|
||||
$commandBus = $this->container->get(CommandBus::class);
|
||||
return $commandBus->handle($command);
|
||||
}
|
||||
|
||||
protected function handleQuery(object $query): mixed
|
||||
{
|
||||
/** @var QueryBus $queryBus */
|
||||
$queryBus = $this->container->get(QueryBus::class);
|
||||
return $queryBus->handle($query);
|
||||
}
|
||||
|
||||
protected function trans(string $key, array $params = [], string $domain = 'messages'): string
|
||||
{
|
||||
/** @var TranslatorInterface $trans */
|
||||
$trans = $this->container->get(TranslatorInterface::class);
|
||||
return $trans->trans($key, $params, $domain);
|
||||
}
|
||||
|
||||
protected function setStatus(int $status): void
|
||||
{
|
||||
$this->response = new Response(status: $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Presentation\Web\Controller;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Class DefaultController.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
final class DefaultController extends AbstractController
|
||||
{
|
||||
#[Route(
|
||||
path: '',
|
||||
name: 'default',
|
||||
methods: ['GET']
|
||||
)]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
return $this->json([
|
||||
'repository' => 'https://github.com/bernard-ng/drc-news-corpus',
|
||||
'title' => 'DRC News Corpus : Towards a scalable and intelligent system for Congolese News curation',
|
||||
'description' => 'The DRC News Corpus is a structured and scalable dataset of news articles sourced from major media outlets covering diverse aspects of the Democratic Republic of Congo (DRC). Designed for efficiency, this system enables the automated collection, processing, and organization of news stories spanning politics, economy, society, culture, environment, and international affairs.',
|
||||
'status' => 200,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\SharedKernel\Presentation\Web\EventListener;
|
||||
|
||||
use App\Aggregator\Domain\Exception\ArticleNotFound;
|
||||
use App\Aggregator\Domain\Exception\SourceNotFound;
|
||||
use App\FeedManagement\Domain\Exception\BookmarkedArticleNotFound;
|
||||
use App\FeedManagement\Domain\Exception\BookmarkNotFound;
|
||||
use App\FeedManagement\Domain\Exception\CommentNotFound;
|
||||
use App\FeedManagement\Domain\Exception\FollowedSourceNotFound;
|
||||
use App\IdentityAndAccess\Domain\Exception\PermissionNotGranted;
|
||||
use App\IdentityAndAccess\Domain\Exception\UserNotFound;
|
||||
use App\SharedKernel\Domain\Exception\InvalidArgument;
|
||||
use App\SharedKernel\Domain\Exception\UserFacingError;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Class UserFacingErrorListener.
|
||||
*
|
||||
* @author bernard-ng <bernard@devscast.tech>
|
||||
*/
|
||||
#[AsEventListener(KernelEvents::EXCEPTION, priority: -1)]
|
||||
final readonly class UserFacingErrorListener
|
||||
{
|
||||
private const array NOT_FOUND_EXCEPTIONS = [
|
||||
ArticleNotFound::class,
|
||||
SourceNotFound::class,
|
||||
UserNotFound::class,
|
||||
CommentNotFound::class,
|
||||
BookmarkNotFound::class,
|
||||
FollowedSourceNotFound::class,
|
||||
BookmarkedArticleNotFound::class,
|
||||
];
|
||||
|
||||
private const array BAD_REQUEST_EXCEPTIONS = [
|
||||
InvalidArgument::class,
|
||||
];
|
||||
|
||||
private const array FORBIDDEN_EXCEPTIONS = [
|
||||
PermissionNotGranted::class,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private TranslatorInterface $translator
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ExceptionEvent $event): void
|
||||
{
|
||||
$exception = $event->getThrowable();
|
||||
if ($exception instanceof UserFacingError) {
|
||||
$message = $this->translator->trans(
|
||||
$exception->translationId(),
|
||||
$exception->translationParameters(),
|
||||
$exception->translationDomain(),
|
||||
);
|
||||
|
||||
$status = $this->getResponseStatus($exception);
|
||||
$response = new JsonResponse([
|
||||
'type' => 'https://symfony.com/errors/domain',
|
||||
'title' => $exception->translationId(),
|
||||
'detail' => $message,
|
||||
'status' => $status,
|
||||
], $status);
|
||||
|
||||
$event->setResponse($response);
|
||||
}
|
||||
}
|
||||
|
||||
public function getResponseStatus(UserFacingError $exception): int
|
||||
{
|
||||
return match (true) {
|
||||
in_array($exception::class, self::NOT_FOUND_EXCEPTIONS) => Response::HTTP_NOT_FOUND,
|
||||
in_array($exception::class, self::BAD_REQUEST_EXCEPTIONS) => Response::HTTP_BAD_REQUEST,
|
||||
in_array($exception::class, self::FORBIDDEN_EXCEPTIONS) => Response::HTTP_FORBIDDEN,
|
||||
default => Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user