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,29 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Basango\IdentityAndAccess\Domain\Model\Identity\LoginAttemptId;
/**
* Class LoginAttempt.
*
* @author bernard-ng <bernard@devscast.tech>
*/
readonly class LoginAttempt
{
public LoginAttemptId $id;
private function __construct(
public User $user,
public \DateTimeImmutable $createdAt = new \DateTimeImmutable()
) {
$this->id = new LoginAttemptId();
}
public static function create(User $user): self
{
return new self($user);
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Basango\IdentityAndAccess\Domain\Event\LoginProfileChanged;
use Basango\IdentityAndAccess\Domain\Model\Identity\LoginHistoryId;
use Basango\SharedKernel\Domain\EventDispatcher\EventEmitterTrait;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\Device;
use Basango\SharedKernel\Domain\Model\ValueObject\Tracking\GeoLocation;
/**
* Class LoginHistory.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class LoginHistory
{
use EventEmitterTrait;
public readonly LoginHistoryId $id;
private function __construct(
public readonly User $user,
public readonly ?string $ipAddress,
public readonly Device $device,
public readonly GeoLocation $location,
public readonly \DateTimeImmutable $createdAt = new \DateTimeImmutable()
) {
$this->id = new LoginHistoryId();
}
public static function create(User $user, ?string $userIp, Device $device, GeoLocation $location): self
{
return new self($user, $userIp, $device, $location);
}
public function matchPreviousProfile(self $previous): self
{
if (
$this->ipAddress !== $previous->ipAddress ||
$this->location->city !== $previous->location->city ||
$this->location->country !== $previous->location->country ||
$this->device->operatingSystem !== $previous->device->operatingSystem
) {
$this->emitEvent(new LoginProfileChanged($this->user->id, $this->device, $this->location));
}
return $this;
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Gesdinet\JWTRefreshTokenBundle\Model\AbstractRefreshToken;
class RefreshToken extends AbstractRefreshToken
{
}
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Basango\IdentityAndAccess\Domain\Event\AccountConfirmed;
use Basango\IdentityAndAccess\Domain\Event\AccountLocked;
use Basango\IdentityAndAccess\Domain\Event\AccountUnlocked;
use Basango\IdentityAndAccess\Domain\Event\ConfirmationRequested;
use Basango\IdentityAndAccess\Domain\Event\EmailUpdated;
use Basango\IdentityAndAccess\Domain\Event\PasswordCreated;
use Basango\IdentityAndAccess\Domain\Event\PasswordForgotten;
use Basango\IdentityAndAccess\Domain\Event\PasswordReset;
use Basango\IdentityAndAccess\Domain\Event\PasswordUpdated;
use Basango\IdentityAndAccess\Domain\Exception\InvalidCurrentPassword;
use Basango\IdentityAndAccess\Domain\Exception\PasswordAlreadyDefined;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Roles;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedCode;
use Basango\IdentityAndAccess\Domain\Service\PasswordHasher;
use Basango\SharedKernel\Domain\EventDispatcher\EventEmitterTrait;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Class User.
*
* @author bernard-ng <bernard@devscast.tech>
*/
class User
{
use EventEmitterTrait;
public readonly UserId $id;
public function __construct(
private(set) string $name,
private(set) EmailAddress $email,
private(set) Roles $roles,
private(set) ?string $password = null,
private(set) bool $isLocked = false,
private(set) bool $isConfirmed = false,
private(set) ?\DateTimeImmutable $updatedAt = null,
public readonly ?\DateTimeImmutable $createdAt = new \DateTimeImmutable(),
) {
$this->id = new UserId();
}
public function lockAccount(VerificationToken $verificationToken): self
{
$this->isLocked = true;
$this->emitEvent(new AccountLocked($this->id, $verificationToken->token));
return $this;
}
public function unlockAccount(): self
{
$this->isLocked = false;
$this->emitEvent(new AccountUnlocked($this->id));
return $this;
}
public function confirmAccount(): self
{
$this->isConfirmed = true;
$this->emitEvent(new AccountConfirmed($this->id));
return $this;
}
public static function register(string $name, EmailAddress $email, ?Roles $roles): self
{
return new self($name, $email, $roles ?? Roles::user());
}
public function updateProfile(string $name, Roles $roles): static
{
$this->name = $name;
$this->roles = $roles;
$this->updatedAt = new \DateTimeImmutable();
return $this;
}
public function updateEmail(EmailAddress $email): self
{
$previous = $this->email;
$this->email = $email;
$this->emitEvent(new EmailUpdated($this->id, $previous, $email));
return $this;
}
public function resetPassword(string $password, PasswordHasher $passwordHasher): void
{
$this->password = $passwordHasher->hash($this, $password);
$this->emitEvent(new PasswordReset($this->id));
}
public function updatePassword(string $current, string $new, PasswordHasher $passwordHasher): self
{
if ($this->password === null || ! $passwordHasher->verify($this, $current)) {
throw new InvalidCurrentPassword();
}
$this->password = $passwordHasher->hash($this, $new);
$this->emitEvent(new PasswordUpdated($this->id));
return $this;
}
public function definePassword(GeneratedCode|string $password, PasswordHasher $passwordHasher): self
{
if ($this->password !== null) {
throw new PasswordAlreadyDefined();
}
$this->password = $passwordHasher->hash($this, (string) $password);
$this->updatedAt = new \DateTimeImmutable();
if ($password instanceof GeneratedCode) {
$this->emitEvent(new PasswordCreated($this->id, $password));
}
return $this;
}
public function requestPasswordReset(VerificationToken $verificationToken): self
{
$this->emitEvent(new PasswordForgotten($this->id, $verificationToken->token));
return $this;
}
public function requestAccountConfirmation(VerificationToken $verificationToken): self
{
$this->emitEvent(new ConfirmationRequested($this->id, $verificationToken->token));
return $this;
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Entity;
use Basango\IdentityAndAccess\Domain\Exception\InvalidVerificationToken;
use Basango\IdentityAndAccess\Domain\Model\Identity\VerificationTokenId;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
/**
* Class VerificationToken.
*
* @author bernard-ng <bernard@devscast.tech>
*/
readonly class VerificationToken
{
public const string DEFAULT_VALIDITY = 'PT2H';
public VerificationTokenId $id;
public function __construct(
public User $user,
public GeneratedToken $token,
public TokenPurpose $purpose,
public \DateTimeImmutable $createdAt = new \DateTimeImmutable()
) {
$this->id = new VerificationTokenId();
}
public static function create(User $user, GeneratedToken $token, TokenPurpose $purpose): self
{
return new self($user, $token, $purpose);
}
public function isExpired(): bool
{
$now = new \DateTimeImmutable();
$validUntil = (\DateTime::createFromImmutable($this->createdAt))
->add(new \DateInterval(self::DEFAULT_VALIDITY));
return $now > $validUntil;
}
public function assertValid(): void
{
if ($this->isExpired()) {
throw new InvalidVerificationToken();
}
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class LoginAttemptId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class LoginAttemptId extends UuidV7
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class LoginHistoryId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class LoginHistoryId extends UuidV7
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class UserId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class UserId extends UuidV7
{
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Identity;
use Symfony\Component\Uid\UuidV7;
/**
* Class VerificationTokenId.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final class VerificationTokenId extends UuidV7
{
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Repository;
use Basango\IdentityAndAccess\Domain\Model\Entity\LoginAttempt;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
/**
* Interface LoginAttemptRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface LoginAttemptRepository
{
public function add(LoginAttempt $loginAttempt): void;
public function remove(LoginAttempt $loginAttempt): void;
public function countBy(User $user): int;
public function deleteBy(User $user): void;
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Repository;
use Basango\IdentityAndAccess\Domain\Model\Entity\LoginHistory;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
/**
* Interface LoginHistoryRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface LoginHistoryRepository
{
public function add(LoginHistory $loginHistory): void;
public function remove(LoginHistory $loginHistory): void;
public function getLastBy(User $user): ?LoginHistory;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Repository;
use Basango\IdentityAndAccess\Domain\Model\Entity\User;
use Basango\IdentityAndAccess\Domain\Model\Identity\UserId;
use Basango\SharedKernel\Domain\Model\ValueObject\EmailAddress;
/**
* Interface UserRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface UserRepository
{
public function add(User $user): void;
public function remove(User $user): void;
public function getById(UserId $userId): User;
public function getByEmail(EmailAddress $email): ?User;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\Repository;
use Basango\IdentityAndAccess\Domain\Model\Entity\VerificationToken;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret\GeneratedToken;
use Basango\IdentityAndAccess\Domain\Model\ValueObject\TokenPurpose;
/**
* Interface LoginAttemptRepository.
*
* @author bernard-ng <bernard@devscast.tech>
*/
interface VerificationTokenRepository
{
public function add(VerificationToken $verificationToken): void;
public function remove(VerificationToken $verificationToken): void;
public function getByToken(GeneratedToken $token, TokenPurpose $purpose): VerificationToken;
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject;
/**
* Class Role.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum Role: string
{
case USER = 'ROLE_USER';
case ADMIN = 'ROLE_ADMIN';
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject;
use Basango\SharedKernel\Domain\Assert;
/**
* @author bernard-ng <bernard@devscast.tech>
*/
readonly class Roles implements \Stringable
{
private array $roles;
public function __construct(array $roles = [Role::USER])
{
Assert::notEmpty($roles, 'identity_and_access.validations.empty_roles');
Assert::allIsInstanceOf($roles, Role::class);
$roles[] = Role::USER;
$this->roles = array_unique(\array_map(fn (Role $role) => $role->value, $roles));
}
#[\Override]
public function __toString(): string
{
return implode(',', $this->roles);
}
public static function admin(): self
{
return new self([Role::USER, Role::ADMIN]);
}
public static function user(): self
{
return new self();
}
public function toArray(): array
{
return $this->roles;
}
public static function fromArray(array $roles): self
{
return new self($roles);
}
public function contains(string $role): bool
{
return in_array($role, $this->roles, true);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret;
use Basango\SharedKernel\Domain\Assert;
/**
* Class GeneratedCode.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GeneratedCode implements \Stringable
{
public function __construct(
public string $code
) {
Assert::notEmpty($this->code);
}
#[\Override]
public function __toString(): string
{
return $this->code;
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject\Secret;
use Basango\SharedKernel\Domain\Assert;
/**
* Class GeneratedToken.
*
* @author bernard-ng <bernard@devscast.tech>
*/
final readonly class GeneratedToken implements \Stringable
{
public function __construct(
public string $token,
) {
Assert::notEmpty($this->token);
}
#[\Override]
public function __toString(): string
{
return $this->token;
}
public function isEqualTo(self $token): bool
{
return $this->token === $token->token;
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Basango\IdentityAndAccess\Domain\Model\ValueObject;
/**
* Enum TokenPurpose.
*
* @author bernard-ng <bernard@devscast.tech>
*/
enum TokenPurpose: string
{
case CONFIRM_ACCOUNT = 'confirm_account';
case PASSWORD_RESET = 'password_reset';
case UNLOCK_ACCOUNT = 'unlock_account';
case DELETE_ACCOUNT = 'delete_account';
}