diff --git a/src/Admin/src/Adapter/AuthenticationAdapter.php b/src/Admin/src/Adapter/AuthenticationAdapter.php index 2742c5e..ff1e092 100644 --- a/src/Admin/src/Adapter/AuthenticationAdapter.php +++ b/src/Admin/src/Adapter/AuthenticationAdapter.php @@ -6,7 +6,7 @@ use Core\Admin\Entity\Admin; use Core\Admin\Entity\AdminIdentity; -use Core\Admin\Entity\AdminRole; +use Core\App\Entity\RoleInterface; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Exception\ORMException; use Dot\DependencyInjection\Attribute\Inject; @@ -139,7 +139,7 @@ public function authenticate(): Result $identityClass->getUuid()->toString(), $identityClass->getIdentity(), $identityClass->getStatus(), - array_map(fn (AdminRole $role): string => $role->getName()->value, $identityClass->getRoles()), + array_map(fn (RoleInterface $role): string => $role->getName()->value, $identityClass->getRoles()), [ 'firstName' => $identityClass->getFirstName(), 'lastName' => $identityClass->getLastName(), diff --git a/src/Core/src/Admin/src/ConfigProvider.php b/src/Core/src/Admin/src/ConfigProvider.php index a9428d9..d9ac5c1 100644 --- a/src/Core/src/Admin/src/ConfigProvider.php +++ b/src/Core/src/Admin/src/ConfigProvider.php @@ -10,10 +10,35 @@ use Core\Admin\Repository\AdminRepository; use Core\Admin\Repository\AdminRoleRepository; use Doctrine\ORM\Mapping\Driver\AttributeDriver; +use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; +/** + * @phpstan-type DoctrineConfigType array{ + * driver: array{ + * orm_default: array{ + * drivers: array, + * }, + * AdminEntities: array{ + * class: class-string, + * cache: non-empty-string, + * paths: non-empty-string[], + * }, + * }, + * types: array, + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * } + */ class ConfigProvider { + /** + * @return array{ + * dependencies: DependenciesType, + * doctrine: DoctrineConfigType, + * } + */ public function __invoke(): array { return [ @@ -22,6 +47,9 @@ public function __invoke(): array ]; } + /** + * @return DependenciesType + */ private function getDependencies(): array { return [ @@ -33,6 +61,9 @@ private function getDependencies(): array ]; } + /** + * @return DoctrineConfigType + */ private function getDoctrineConfig(): array { return [ diff --git a/src/Core/src/Admin/src/Entity/Admin.php b/src/Core/src/Admin/src/Entity/Admin.php index 44e2ba5..cb2bd08 100644 --- a/src/Core/src/Admin/src/Entity/Admin.php +++ b/src/Core/src/Admin/src/Entity/Admin.php @@ -11,6 +11,7 @@ use Core\App\Entity\RoleInterface; use Core\App\Entity\TimestampsTrait; use Core\Setting\Entity\Setting; +use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -18,6 +19,9 @@ use function array_map; +/** + * @phpstan-import-type RoleType from RoleInterface + */ #[ORM\Entity(repositoryClass: AdminRepository::class)] #[ORM\Table(name: 'admin')] #[ORM\HasLifecycleCallbacks] @@ -26,6 +30,7 @@ class Admin extends AbstractEntity implements UserEntityInterface use PasswordTrait; use TimestampsTrait; + /** @var non-empty-string|null $identity */ #[ORM\Column(name: 'identity', type: 'string', length: 191, unique: true)] protected ?string $identity = null; @@ -45,12 +50,14 @@ enumType: AdminStatusEnum::class, )] protected AdminStatusEnum $status = AdminStatusEnum::Active; + /** @var Collection $roles */ #[ORM\ManyToMany(targetEntity: AdminRole::class)] #[ORM\JoinTable(name: 'admin_roles')] #[ORM\JoinColumn(name: 'userUuid', referencedColumnName: 'uuid')] #[ORM\InverseJoinColumn(name: 'roleUuid', referencedColumnName: 'uuid')] protected Collection $roles; + /** @var Collection $settings */ #[ORM\OneToMany(targetEntity: Setting::class, mappedBy: 'admin')] protected Collection $settings; @@ -73,6 +80,9 @@ public function hasIdentity(): bool return $this->identity !== null; } + /** + * @param non-empty-string $identity + */ public function setIdentity(string $identity): self { $this->identity = $identity; @@ -128,14 +138,22 @@ public function setStatus(AdminStatusEnum $status): self return $this; } + /** + * @return RoleInterface[] + */ public function getRoles(): array { return $this->roles->toArray(); } - public function setRoles(ArrayCollection $roles): self + /** + * @param RoleInterface[] $roles + */ + public function setRoles(array $roles): self { - $this->roles = $roles; + foreach ($roles as $role) { + $this->roles->add($role); + } return $this; } @@ -196,9 +214,21 @@ public function isActive(): bool public function getIdentifier(): string { - return $this->identity; - } - + return (string) $this->identity; + } + + /** + * @return array{ + * uuid: non-empty-string, + * identity: non-empty-string|null, + * firstName: string|null, + * lastName: string|null, + * status: non-empty-string, + * roles: iterable, + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null, + * } + */ public function getArrayCopy(): array { return [ @@ -207,7 +237,7 @@ public function getArrayCopy(): array 'firstName' => $this->firstName, 'lastName' => $this->lastName, 'status' => $this->status->value, - 'roles' => array_map(fn (AdminRole $role): array => $role->getArrayCopy(), $this->roles->toArray()), + 'roles' => array_map(fn (RoleInterface $role): array => $role->getArrayCopy(), $this->roles->toArray()), 'created' => $this->created, 'updated' => $this->updated, ]; diff --git a/src/Core/src/Admin/src/Entity/AdminIdentity.php b/src/Core/src/Admin/src/Entity/AdminIdentity.php index 87f7bf7..d3d318f 100644 --- a/src/Core/src/Admin/src/Entity/AdminIdentity.php +++ b/src/Core/src/Admin/src/Entity/AdminIdentity.php @@ -9,12 +9,16 @@ class AdminIdentity implements UserInterface { + /** + * @param non-empty-string[] $roles + * @param array $details + */ public function __construct( public string $uuid, public string $identity, public AdminStatusEnum $status, public array $roles = [], - public array $details = [] + public array $details = [], ) { } diff --git a/src/Core/src/Admin/src/Entity/AdminLogin.php b/src/Core/src/Admin/src/Entity/AdminLogin.php index 5569505..46b1790 100644 --- a/src/Core/src/Admin/src/Entity/AdminLogin.php +++ b/src/Core/src/Admin/src/Entity/AdminLogin.php @@ -9,6 +9,7 @@ use Core\App\Entity\TimestampsTrait; use Core\App\Enum\SuccessFailureEnum; use Core\App\Enum\YesNoEnum; +use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: AdminLoginRepository::class)] @@ -43,7 +44,7 @@ class AdminLogin extends AbstractEntity protected ?string $deviceModel = null; #[ORM\Column(type: 'yes_no_enum', nullable: true, enumType: YesNoEnum::class)] - protected ?YesNoEnum $isMobile = null; + protected YesNoEnum $isMobile = YesNoEnum::No; #[ORM\Column(name: 'osName', type: 'string', length: 191, nullable: true)] protected ?string $osName = null; @@ -67,7 +68,14 @@ class AdminLogin extends AbstractEntity protected ?string $clientVersion = null; #[ORM\Column(type: 'success_failure_enum', nullable: true, enumType: SuccessFailureEnum::class)] - protected ?SuccessFailureEnum $loginStatus = null; + protected SuccessFailureEnum $loginStatus = SuccessFailureEnum::Fail; + + public function __construct() + { + parent::__construct(); + + $this->created(); + } public function getIdentity(): ?string { @@ -170,7 +178,7 @@ public function getIsMobile(): ?YesNoEnum return $this->isMobile; } - public function setIsMobile(?YesNoEnum $isMobile): self + public function setIsMobile(YesNoEnum $isMobile): self { $this->isMobile = $isMobile; @@ -266,13 +274,37 @@ public function getLoginStatus(): ?SuccessFailureEnum return $this->loginStatus; } - public function setLoginStatus(?SuccessFailureEnum $loginStatus): self + public function setLoginStatus(SuccessFailureEnum $loginStatus): self { $this->loginStatus = $loginStatus; return $this; } + /** + * @return array{ + * uuid: non-empty-string, + * identity: string|null, + * adminIp: string|null, + * country: string|null, + * continent: string|null, + * organization: string|null, + * deviceType: string|null, + * deviceBrand: string|null, + * deviceModel: string|null, + * isMobile: string, + * osName: string|null, + * osVersion: string|null, + * osPlatform: string|null, + * clientType: string|null, + * clientName: string|null, + * clientEngine: string|null, + * clientVersion: string|null, + * loginStatus: string, + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null, + * } + */ public function getArrayCopy(): array { return [ diff --git a/src/Core/src/Admin/src/Entity/AdminRole.php b/src/Core/src/Admin/src/Entity/AdminRole.php index bcc7227..fb36102 100644 --- a/src/Core/src/Admin/src/Entity/AdminRole.php +++ b/src/Core/src/Admin/src/Entity/AdminRole.php @@ -12,6 +12,9 @@ use Core\App\Entity\TimestampsTrait; use Doctrine\ORM\Mapping as ORM; +/** + * @phpstan-import-type RoleType from RoleInterface + */ #[ORM\Entity(repositoryClass: AdminRoleRepository::class)] #[ORM\Table(name: 'admin_role')] #[ORM\HasLifecycleCallbacks] @@ -50,6 +53,9 @@ public function setName(BackedEnum $name): self return $this; } + /** + * @return RoleType + */ public function getArrayCopy(): array { return [ diff --git a/src/Core/src/Admin/src/Enum/AdminRoleEnum.php b/src/Core/src/Admin/src/Enum/AdminRoleEnum.php index fe828b9..58426f9 100644 --- a/src/Core/src/Admin/src/Enum/AdminRoleEnum.php +++ b/src/Core/src/Admin/src/Enum/AdminRoleEnum.php @@ -11,6 +11,9 @@ enum AdminRoleEnum: string case Admin = 'admin'; case Superuser = 'superuser'; + /** + * @return non-empty-string[] + */ public static function values(): array { return array_column(self::cases(), 'value'); diff --git a/src/Core/src/Admin/src/Enum/AdminStatusEnum.php b/src/Core/src/Admin/src/Enum/AdminStatusEnum.php index 6517b45..9fd605a 100644 --- a/src/Core/src/Admin/src/Enum/AdminStatusEnum.php +++ b/src/Core/src/Admin/src/Enum/AdminStatusEnum.php @@ -12,11 +12,17 @@ enum AdminStatusEnum: string case Active = 'active'; case Inactive = 'inactive'; + /** + * @return non-empty-string[] + */ public static function values(): array { return array_column(self::cases(), 'value'); } + /** + * @return array + */ public static function toArray(): array { return array_reduce(self::cases(), function (array $collector, self $enum): array { diff --git a/src/Core/src/Admin/src/Repository/AdminLoginRepository.php b/src/Core/src/Admin/src/Repository/AdminLoginRepository.php index 87029d3..ade964e 100644 --- a/src/Core/src/Admin/src/Repository/AdminLoginRepository.php +++ b/src/Core/src/Admin/src/Repository/AdminLoginRepository.php @@ -17,6 +17,9 @@ #[Entity(AdminLogin::class)] class AdminLoginRepository extends AbstractRepository { + /** + * @return non-empty-string[] + */ public function getAdminLoginIdentities(): array { $results = $this->getQueryBuilder() @@ -28,6 +31,10 @@ public function getAdminLoginIdentities(): array return array_column($results, 'identity'); } + /** + * @param array $params + * @param array $filters + */ public function getAdminLogins(array $params = [], array $filters = []): QueryBuilder { $queryBuilder = $this diff --git a/src/Core/src/Admin/src/Repository/AdminRepository.php b/src/Core/src/Admin/src/Repository/AdminRepository.php index 83c5958..f366ac5 100644 --- a/src/Core/src/Admin/src/Repository/AdminRepository.php +++ b/src/Core/src/Admin/src/Repository/AdminRepository.php @@ -16,6 +16,10 @@ #[Entity(name: Admin::class)] class AdminRepository extends AbstractRepository { + /** + * @param array $params + * @param array $filters + */ public function getAdmins(array $params = [], array $filters = []): QueryBuilder { $queryBuilder = $this diff --git a/src/Core/src/Admin/src/Repository/AdminRoleRepository.php b/src/Core/src/Admin/src/Repository/AdminRoleRepository.php index 4b7f73b..59b02fa 100644 --- a/src/Core/src/Admin/src/Repository/AdminRoleRepository.php +++ b/src/Core/src/Admin/src/Repository/AdminRoleRepository.php @@ -16,6 +16,10 @@ #[Entity(name: AdminRole::class)] class AdminRoleRepository extends AbstractRepository { + /** + * @param array $params + * @param array $filters + */ public function getAdminRoles(array $params = [], array $filters = []): QueryBuilder { $queryBuilder = $this diff --git a/src/Core/src/App/src/ConfigProvider.php b/src/Core/src/App/src/ConfigProvider.php index a3d93e6..c3cfc31 100644 --- a/src/Core/src/App/src/ConfigProvider.php +++ b/src/Core/src/App/src/ConfigProvider.php @@ -12,6 +12,8 @@ use Core\App\Service\MailService; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\EntityListenerResolver as EntityListenerResolverInterface; +use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Doctrine\Persistence\Mapping\Driver\MappingDriverChain; use Dot\Cache\Adapter\ArrayAdapter; use Dot\Cache\Adapter\FilesystemAdapter; @@ -25,13 +27,80 @@ use Ramsey\Uuid\Doctrine\UuidBinaryType; use Ramsey\Uuid\Doctrine\UuidType; use Roave\PsrContainerDoctrine\EntityManagerFactory; +use Symfony\Component\Cache\Adapter\AdapterInterface; use function getcwd; +/** + * @phpstan-type DoctrineConfigType array{ + * cache: array{ + * array: array{ + * class: class-string, + * }, + * filesystem: array{ + * class: class-string, + * directory: non-empty-string, + * namespace: non-empty-string, + * }, + * }, + * configuration: array{ + * orm_default: array{ + * entity_listener_resolver: class-string, + * result_cache: non-empty-string, + * metadata_cache: non-empty-string, + * query_cache: non-empty-string, + * hydration_cache: non-empty-string, + * typed_field_mapper: non-empty-string|null, + * second_level_cache: array{ + * enabled: bool, + * default_lifetime: int, + * default_lock_lifetime: int, + * file_lock_region_directory: string, + * regions: non-empty-string[], + * }, + * }, + * }, + * connection: array{ + * orm_default: array{ + * doctrine_mapping_types: array, + * }, + * }, + * driver: array{ + * orm_default: array{ + * class: class-string, + * }, + * }, + * fixtures: non-empty-string, + * migrations: array{ + * table_storage: array{ + * table_name: non-empty-string, + * version_column_name: non-empty-string, + * version_column_length: int, + * executed_at_column_name: non-empty-string, + * execution_time_column_name: non-empty-string, + * }, + * migrations_paths: array, + * all_or_nothing: bool, + * check_database_platform: bool, + * }, + * types: array, + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * aliases: array, + * } + */ class ConfigProvider { public const REGEXP_UUID = '{uuid:[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}}'; + /** + * @return array{ + * dependencies: DependenciesType, + * doctrine: DoctrineConfigType, + * resultCacheLifetime: int, + * } + */ public function __invoke(): array { return [ @@ -41,6 +110,9 @@ public function __invoke(): array ]; } + /** + * @return DependenciesType + */ private function getDependencies(): array { return [ @@ -61,6 +133,9 @@ private function getDependencies(): array ]; } + /** + * @return DoctrineConfigType + */ private function getDoctrineConfig(): array { return [ @@ -100,8 +175,8 @@ private function getDoctrineConfig(): array ], ], 'driver' => [ - // default metadata driver, aggregates all other drivers into a single one. - // Override `orm_default` only if you know what you're doing + // The default metadata driver aggregates all other drivers into a single one. + // Override `orm_default` only if you know what you're doing. 'orm_default' => [ 'class' => MappingDriverChain::class, ], diff --git a/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php b/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php index efa9cc2..c62b044 100644 --- a/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php +++ b/src/Core/src/App/src/DBAL/Types/AbstractEnumType.php @@ -21,7 +21,7 @@ public function getSQLDeclaration(array $column, AbstractPlatform $platform): st return 'TEXT'; } - $values = array_map(fn($case) => "'{$case->value}'", $this->getEnumValues()); + $values = array_map(fn($case) => "'$case->value'", $this->getEnumValues()); return sprintf('ENUM(%s)', implode(', ', $values)); } @@ -41,6 +41,9 @@ public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform) */ abstract protected function getEnumClass(): string; + /** + * @return BackedEnum[] + */ private function getEnumValues(): array { return $this->getEnumClass()::cases(); diff --git a/src/Core/src/App/src/Entity/AbstractEntity.php b/src/Core/src/App/src/Entity/AbstractEntity.php index f2848a5..df36e02 100644 --- a/src/Core/src/App/src/Entity/AbstractEntity.php +++ b/src/Core/src/App/src/Entity/AbstractEntity.php @@ -38,6 +38,9 @@ public function isDeleted(): bool return false; } + /** + * @param array $array + */ public function exchangeArray(array $array): void { foreach ($array as $property => $values) { diff --git a/src/Core/src/App/src/Entity/Guest.php b/src/Core/src/App/src/Entity/Guest.php index a8ac112..3f150b3 100644 --- a/src/Core/src/App/src/Entity/Guest.php +++ b/src/Core/src/App/src/Entity/Guest.php @@ -7,13 +7,14 @@ use Core\User\Entity\UserRole; use Core\User\Enum\UserRoleEnum; use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; class Guest { protected string $identity = UserRoleEnum::Guest->value; - /** @var ArrayCollection */ - protected ArrayCollection $roles; + /** @var Collection */ + protected Collection $roles; public function __construct() { @@ -36,14 +37,22 @@ public function setIdentity(string $identity): self return $this; } + /** + * @return RoleInterface[] + */ public function getRoles(): array { return $this->roles->toArray(); } - public function setRoles(ArrayCollection $roles): self + /** + * @param RoleInterface[] $roles + */ + public function setRoles(array $roles): self { - $this->roles = $roles; + foreach ($roles as $role) { + $this->roles->add($role); + } return $this; } diff --git a/src/Core/src/App/src/Entity/PasswordTrait.php b/src/Core/src/App/src/Entity/PasswordTrait.php index 30c3f38..412652e 100644 --- a/src/Core/src/App/src/Entity/PasswordTrait.php +++ b/src/Core/src/App/src/Entity/PasswordTrait.php @@ -25,6 +25,6 @@ public function hashPassword(string $password): string public function verifyPassword(string $password): bool { - return password_verify($password, $this->password); + return password_verify($password, (string) $this->password); } } diff --git a/src/Core/src/App/src/Entity/RoleInterface.php b/src/Core/src/App/src/Entity/RoleInterface.php index 0c2b80b..2a2e138 100644 --- a/src/Core/src/App/src/Entity/RoleInterface.php +++ b/src/Core/src/App/src/Entity/RoleInterface.php @@ -5,10 +5,24 @@ namespace Core\App\Entity; use BackedEnum; +use DateTimeImmutable; +/** + * @phpstan-type RoleType array{ + * uuid: non-empty-string, + * name: non-empty-string, + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null + * } + */ interface RoleInterface extends EntityInterface { - public function getName(): ?BackedEnum; + public function getName(): BackedEnum; public function setName(BackedEnum $name): RoleInterface; + + /** + * @return RoleType + */ + public function getArrayCopy(): array; } diff --git a/src/Core/src/App/src/Enum/SuccessFailureEnum.php b/src/Core/src/App/src/Enum/SuccessFailureEnum.php index 7a1b5fa..a1bbc7e 100644 --- a/src/Core/src/App/src/Enum/SuccessFailureEnum.php +++ b/src/Core/src/App/src/Enum/SuccessFailureEnum.php @@ -11,6 +11,9 @@ enum SuccessFailureEnum: string case Success = 'success'; case Fail = 'fail'; + /** + * @return non-empty-string[] + */ public static function values(): array { return array_column(self::cases(), 'value'); diff --git a/src/Core/src/App/src/Enum/YesNoEnum.php b/src/Core/src/App/src/Enum/YesNoEnum.php index 2aa37aa..38454fe 100644 --- a/src/Core/src/App/src/Enum/YesNoEnum.php +++ b/src/Core/src/App/src/Enum/YesNoEnum.php @@ -11,6 +11,9 @@ enum YesNoEnum: string case Yes = 'yes'; case No = 'no'; + /** + * @return non-empty-string[] + */ public static function values(): array { return array_column(self::cases(), 'value'); diff --git a/src/Core/src/App/src/Helper/Paginator.php b/src/Core/src/App/src/Helper/Paginator.php index c9fdcc7..7b62ac9 100644 --- a/src/Core/src/App/src/Helper/Paginator.php +++ b/src/Core/src/App/src/Helper/Paginator.php @@ -16,12 +16,22 @@ use function range; use function strlen; +/** + * @template T + */ class Paginator { /** - * @param array $params + * @param array $params * @param non-empty-string $sort - * @return array{offset: int, limit: int, page: int, sort: string, dir: string} + * @param non-empty-string $dir + * @return array{ + * offset: int, + * limit: int, + * page: int, + * sort: non-empty-string, + * dir: non-empty-string + * } */ public static function getParams(array $params, string $sort, string $dir = 'desc'): array { @@ -70,6 +80,12 @@ public static function getParams(array $params, string $sort, string $dir = 'des ]; } + /** + * @param DoctrinePaginator $paginator + * @param array $params + * @param array $filters + * @return array + */ public static function wrapper(DoctrinePaginator $paginator, array $params = [], array $filters = []): array { $params['count'] = $paginator->count(); diff --git a/src/Core/src/App/src/Message.php b/src/Core/src/App/src/Message.php index f3d7a8d..1ef9f49 100644 --- a/src/Core/src/App/src/Message.php +++ b/src/Core/src/App/src/Message.php @@ -74,31 +74,50 @@ class Message public const VALIDATOR_REQUIRED_FIELD = 'This field is required and cannot be empty.'; public const VALIDATOR_REQUIRED_UPLOAD = 'A file must be uploaded first.'; + /** + * @return non-empty-string + */ public static function invalidConfig(string $config): string { return sprintf(self::INVALID_CONFIG, $config); } + /** + * @return non-empty-string + */ public static function invalidValue(string $value): string { return sprintf(self::INVALID_VALUE, $value); } + /** + * @return non-empty-string + */ public static function missingConfig(string $config): string { return sprintf(self::MISSING_CONFIG, $config); } + /** + * @return non-empty-string + */ public static function mailNotSentTo(string $email): string { return sprintf(self::MAIL_NOT_SENT_TO, $email); } + /** + * @return non-empty-string + */ public static function mailSentUserActivation(string $email): string { return sprintf(self::MAIL_SENT_USER_ACTIVATION, $email); } + /** + * @param string[] $types + * @return non-empty-string + */ public static function notAcceptable(array $types = []): string { if (count($types) === 0) { @@ -108,41 +127,67 @@ public static function notAcceptable(array $types = []): string return sprintf('%s Supported types: %s', self::NOT_ACCEPTABLE, implode(', ', $types)); } + /** + * @return non-empty-string + */ public static function resourceAlreadyRegistered(string $resource): string { return sprintf(self::RESOURCE_ALREADY_REGISTERED, $resource); } + /** + * @return non-empty-string + */ public static function resourceNotFound(string $resource = 'Resource'): string { return sprintf(self::RESOURCE_NOT_FOUND, $resource); } + /** + * @return non-empty-string + */ public static function restrictionDeprecation(string $first, string $second): string { return sprintf(self::RESTRICTION_DEPRECATION, $first, $second); } + /** + * @param string[] $mimeTypes + * @return non-empty-string + */ public static function restrictionImage(array $mimeTypes): string { return sprintf(self::RESTRICTION_IMAGE, implode(',', $mimeTypes)); } + /** + * @return non-empty-string + */ public static function serviceNotFound(string $service): string { return sprintf(self::SERVICE_NOT_FOUND, $service); } + /** + * @return non-empty-string + */ public static function settingNotFound(string $identifier): string { return sprintf(self::SETTING_NOT_FOUND, $identifier); } + /** + * @return non-empty-string + */ public static function templateNotFound(string $template): string { return sprintf(self::TEMPLATE_NOT_FOUND, $template); } + /** + * @param string[] $types + * @return non-empty-string + */ public static function unsupportedMediaType(array $types = []): string { if (count($types) === 0) { @@ -152,21 +197,33 @@ public static function unsupportedMediaType(array $types = []): string return sprintf('%s Supported types: %s', self::UNSUPPORTED_MEDIA_TYPE, implode(', ', $types)); } + /** + * @return non-empty-string + */ public static function validatorLengthMax(int $max): string { return sprintf(self::VALIDATOR_LENGTH_MAX, $max); } + /** + * @return non-empty-string + */ public static function validatorLengthMin(int $min): string { return sprintf(self::VALIDATOR_LENGTH_MIN, $min); } + /** + * @return non-empty-string + */ public static function validatorLengthMinMax(int $min, int $max): string { return sprintf(self::VALIDATOR_LENGTH_MIN_MAX, $min, $max); } + /** + * @return non-empty-string + */ public static function validatorMismatch(string $first, string $second): string { return sprintf(self::VALIDATOR_MISMATCH, $first, $second); diff --git a/src/Core/src/App/src/Service/IpService.php b/src/Core/src/App/src/Service/IpService.php index 0c3b708..bb46cab 100644 --- a/src/Core/src/App/src/Service/IpService.php +++ b/src/Core/src/App/src/Service/IpService.php @@ -15,6 +15,13 @@ class IpService { + /** + * @phpstan-param array{ + * HTTP_X_FORWARDED_FOR?: string, + * HTTP_CLIENT_IP?: string, + * REMOTE_ADDR?: string, + * } $server + */ public static function getUserIp(array $server): mixed { if (! empty($server)) { @@ -29,10 +36,10 @@ public static function getUserIp(array $server): mixed } } else { // check if HTTP_X_FORWARDED_FOR is public network IP - if (getenv('HTTP_X_FORWARDED_FOR') && self::isPublicIp(getenv('HTTP_X_FORWARDED_FOR'))) { + if (getenv('HTTP_X_FORWARDED_FOR') && self::isPublicIp((string) getenv('HTTP_X_FORWARDED_FOR'))) { $realIp = getenv('HTTP_X_FORWARDED_FOR'); // check if HTTP_CLIENT_IP is public network IP - } elseif (getenv('HTTP_CLIENT_IP') && self::isPublicIp(getenv('HTTP_CLIENT_IP'))) { + } elseif (getenv('HTTP_CLIENT_IP') && self::isPublicIp((string) getenv('HTTP_CLIENT_IP'))) { $realIp = getenv('HTTP_CLIENT_IP'); } else { $realIp = getenv('REMOTE_ADDR'); diff --git a/src/Core/src/App/src/Service/MailService.php b/src/Core/src/App/src/Service/MailService.php index 80f0272..658ddc6 100644 --- a/src/Core/src/App/src/Service/MailService.php +++ b/src/Core/src/App/src/Service/MailService.php @@ -15,6 +15,9 @@ class MailService { + /** + * @param array $config + */ #[Inject( 'dot-mail.service.default', 'dot-log.default_logger', @@ -36,7 +39,7 @@ public function sendActivationMail(User $user, string $body): bool return false; } - $this->mailService->getMessage()->addTo($user->getDetail()->getEmail(), $user->getName()); + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); $this->mailService->setSubject('Welcome to ' . $this->config['application']['name']); $this->mailService->setBody($body); @@ -44,7 +47,7 @@ public function sendActivationMail(User $user, string $body): bool return $this->mailService->send()->isValid(); } catch (MailException | TransportExceptionInterface $exception) { $this->logger->err($exception->getMessage()); - throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getDetail()->getEmail())); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); } } @@ -53,7 +56,7 @@ public function sendActivationMail(User $user, string $body): bool */ public function sendResetPasswordRequestedMail(User $user, string $body): bool { - $this->mailService->getMessage()->addTo($user->getDetail()->getEmail(), $user->getName()); + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); $this->mailService->setSubject( 'Reset password instructions for your ' . $this->config['application']['name'] . ' account' ); @@ -63,7 +66,7 @@ public function sendResetPasswordRequestedMail(User $user, string $body): bool return $this->mailService->send()->isValid(); } catch (MailException | TransportExceptionInterface $exception) { $this->logger->err($exception->getMessage()); - throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getDetail()->getEmail())); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); } } @@ -72,7 +75,7 @@ public function sendResetPasswordRequestedMail(User $user, string $body): bool */ public function sendResetPasswordCompletedMail(User $user, string $body): bool { - $this->mailService->getMessage()->addTo($user->getDetail()->getEmail(), $user->getName()); + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); $this->mailService->setSubject( 'You have successfully reset the password for your ' . $this->config['application']['name'] . ' account' ); @@ -82,7 +85,7 @@ public function sendResetPasswordCompletedMail(User $user, string $body): bool return $this->mailService->send()->isValid(); } catch (MailException | TransportExceptionInterface $exception) { $this->logger->err($exception->getMessage()); - throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getDetail()->getEmail())); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); } } @@ -91,7 +94,7 @@ public function sendResetPasswordCompletedMail(User $user, string $body): bool */ public function sendRecoverIdentityMail(User $user, string $body): bool { - $this->mailService->getMessage()->addTo($user->getDetail()->getEmail(), $user->getName()); + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); $this->mailService->setSubject( 'Recover identity for your ' . $this->config['application']['name'] . ' account' ); @@ -101,7 +104,7 @@ public function sendRecoverIdentityMail(User $user, string $body): bool return $this->mailService->send()->isValid(); } catch (MailException | TransportExceptionInterface $exception) { $this->logger->err($exception->getMessage()); - throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getDetail()->getEmail())); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); } } @@ -110,7 +113,7 @@ public function sendRecoverIdentityMail(User $user, string $body): bool */ public function sendWelcomeMail(User $user, string $body): bool { - $this->mailService->getMessage()->addTo($user->getDetail()->getEmail(), $user->getName()); + $this->mailService->getMessage()->addTo($user->getEmail(), $user->getName()); $this->mailService->setSubject('Welcome to ' . $this->config['application']['name']); $this->mailService->setBody($body); @@ -118,7 +121,7 @@ public function sendWelcomeMail(User $user, string $body): bool return $this->mailService->send()->isValid(); } catch (MailException | TransportExceptionInterface $exception) { $this->logger->err($exception->getMessage()); - throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getDetail()->getEmail())); + throw new MailException(sprintf(Message::MAIL_NOT_SENT_TO, $user->getEmail())); } } } diff --git a/src/Core/src/Security/src/ConfigProvider.php b/src/Core/src/Security/src/ConfigProvider.php index 683e78d..99f5a9c 100644 --- a/src/Core/src/Security/src/ConfigProvider.php +++ b/src/Core/src/Security/src/ConfigProvider.php @@ -10,10 +10,34 @@ use Core\Security\Repository\OAuthRefreshTokenRepository; use Core\Security\Repository\OAuthScopeRepository; use Doctrine\ORM\Mapping\Driver\AttributeDriver; +use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; +/** + * @phpstan-type DoctrineConfigType array{ + * driver: array{ + * orm_default: array{ + * drivers: array, + * }, + * SecurityEntities: array{ + * class: class-string, + * cache: string, + * paths: array, + * }, + * } + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * } + */ class ConfigProvider { + /** + * @return array{ + * dependencies: DependenciesType, + * doctrine: DoctrineConfigType, + * } + */ public function __invoke(): array { return [ @@ -22,6 +46,9 @@ public function __invoke(): array ]; } + /** + * @return DependenciesType + */ private function getDependencies(): array { return [ @@ -35,6 +62,9 @@ private function getDependencies(): array ]; } + /** + * @return DoctrineConfigType + */ private function getDoctrineConfig(): array { return [ diff --git a/src/Core/src/Security/src/Entity/OAuthAccessToken.php b/src/Core/src/Security/src/Entity/OAuthAccessToken.php index 2f5f2b1..f70c9c9 100644 --- a/src/Core/src/Security/src/Entity/OAuthAccessToken.php +++ b/src/Core/src/Security/src/Entity/OAuthAccessToken.php @@ -20,6 +20,8 @@ use League\OAuth2\Server\Entities\ScopeEntityInterface; use RuntimeException; +use function is_int; + #[ORM\Entity(repositoryClass: OAuthAccessTokenRepository::class)] #[ORM\Table(name: 'oauth_access_tokens')] class OAuthAccessToken implements AccessTokenEntityInterface @@ -36,12 +38,14 @@ class OAuthAccessToken implements AccessTokenEntityInterface #[ORM\Column(name: 'user_id', type: 'string', length: 25, nullable: true)] private ?string $userId; + /** @var non-empty-string $token */ #[ORM\Column(name: 'token', type: 'string', length: 100)] private string $token; #[ORM\Column(name: 'revoked', type: 'boolean', options: ['default' => false])] private bool $isRevoked = false; + /** @var Collection */ #[ORM\ManyToMany(targetEntity: OAuthScope::class, inversedBy: 'accessTokens', indexBy: 'id')] #[ORM\JoinTable(name: 'oauth_access_token_scopes')] #[ORM\JoinColumn(name: 'access_token_id', referencedColumnName: 'id')] @@ -84,11 +88,17 @@ public function getClient(): ClientEntityInterface return $this->client; } + /** + * @return non-empty-string + */ public function getToken(): string { return $this->token; } + /** + * @param non-empty-string $token + */ public function setToken(string $token): self { $this->token = $token; @@ -115,6 +125,9 @@ public function revoke(): self return $this; } + /** + * @return non-empty-string + */ public function getIdentifier(): string { return $this->getToken(); @@ -133,6 +146,10 @@ public function setIdentifier($identifier): self */ public function setUserIdentifier($identifier): self { + if (is_int($identifier)) { + $identifier = (string) $identifier; + } + $this->userId = $identifier; return $this; @@ -195,12 +212,13 @@ public function initJwtConfiguration(): self throw new RuntimeException('Unable to init JWT without private key'); } + /** @var non-empty-string $keyContents */ + $keyContents = $this->privateKey->getKeyContents(); + $passphrase = (string) $this->privateKey->getPassPhrase(); + $this->jwtConfiguration = Configuration::forAsymmetricSigner( new Sha256(), - InMemory::plainText( - $this->privateKey->getKeyContents(), - $this->privateKey->getPassPhrase() ?? '' - ), + InMemory::plainText($keyContents, $passphrase), InMemory::plainText('/') ); @@ -215,13 +233,17 @@ private function convertToJWT(): Token throw new RuntimeException('Unable to convert to JWT without config'); } + /** @var non-empty-string $audiences */ + $audiences = $this->getClient()->getIdentifier(); + /** @var non-empty-string $subject */ + $subject = (string) $this->getUserIdentifier(); return $this->jwtConfiguration->builder() - ->permittedFor($this->getClient()->getIdentifier()) + ->permittedFor($audiences) ->identifiedBy($this->getIdentifier()) ->issuedAt(new DateTimeImmutable()) ->canOnlyBeUsedAfter(new DateTimeImmutable()) ->expiresAt($this->getExpiryDateTime()) - ->relatedTo($this->getUserIdentifier()) + ->relatedTo($subject) ->withClaim('scopes', $this->getScopes()) ->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey()); } diff --git a/src/Core/src/Security/src/Entity/OAuthAuthCode.php b/src/Core/src/Security/src/Entity/OAuthAuthCode.php index 5123cb7..72369c2 100644 --- a/src/Core/src/Security/src/Entity/OAuthAuthCode.php +++ b/src/Core/src/Security/src/Entity/OAuthAuthCode.php @@ -35,6 +35,7 @@ class OAuthAuthCode implements AuthCodeEntityInterface #[ORM\Column(name: 'revoked', type: 'boolean', options: ['default' => false])] private bool $isRevoked = false; + /** @var Collection */ #[ORM\ManyToMany(targetEntity: OAuthScope::class, inversedBy: 'authCodes', indexBy: 'id')] #[ORM\JoinTable(name: 'oauth_auth_code_scopes')] #[ORM\JoinColumn(name: 'auth_code_id', referencedColumnName: 'id')] @@ -137,7 +138,7 @@ public function addScope(ScopeEntityInterface $scope): self return $this; } - public function removeScope(OAuthScope $scope): self + public function removeScope(ScopeEntityInterface $scope): self { $this->scopes->removeElement($scope); diff --git a/src/Core/src/Security/src/Entity/OAuthClient.php b/src/Core/src/Security/src/Entity/OAuthClient.php index ed4c2ba..a2b4da3 100644 --- a/src/Core/src/Security/src/Entity/OAuthClient.php +++ b/src/Core/src/Security/src/Entity/OAuthClient.php @@ -26,7 +26,7 @@ class OAuthClient implements ClientEntityInterface private ?User $user = null; #[ORM\Column(name: 'name', type: 'string', length: 40)] - private string $name = ''; + private string $name; #[ORM\Column(name: 'secret', type: 'string', length: 100, nullable: true)] private ?string $secret = null; diff --git a/src/Core/src/Security/src/Entity/OAuthScope.php b/src/Core/src/Security/src/Entity/OAuthScope.php index c642b11..f90e52f 100644 --- a/src/Core/src/Security/src/Entity/OAuthScope.php +++ b/src/Core/src/Security/src/Entity/OAuthScope.php @@ -9,6 +9,8 @@ use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\AuthCodeEntityInterface; use League\OAuth2\Server\Entities\ScopeEntityInterface; use League\OAuth2\Server\Entities\Traits\ScopeTrait; @@ -26,9 +28,11 @@ class OAuthScope implements ScopeEntityInterface #[ORM\Column(name: 'scope', type: 'string', length: 191)] private string $scope = ''; + /** @var Collection */ #[ORM\ManyToMany(targetEntity: OAuthAccessToken::class, mappedBy: 'scopes')] protected Collection $accessTokens; + /** @var Collection */ #[ORM\ManyToMany(targetEntity: OAuthAuthCode::class, mappedBy: 'scopes')] protected Collection $authCodes; @@ -67,7 +71,7 @@ public function getScope(): string return $this->scope; } - public function addAccessToken(OAuthAccessToken $accessToken): self + public function addAccessToken(AccessTokenEntityInterface $accessToken): self { if (! $this->accessTokens->contains($accessToken)) { $this->accessTokens->add($accessToken); @@ -76,7 +80,7 @@ public function addAccessToken(OAuthAccessToken $accessToken): self return $this; } - public function removeAccessToken(OAuthAccessToken $accessToken): self + public function removeAccessToken(AccessTokenEntityInterface $accessToken): self { if ($this->accessTokens->contains($accessToken)) { $this->accessTokens->removeElement($accessToken); @@ -85,6 +89,9 @@ public function removeAccessToken(OAuthAccessToken $accessToken): self return $this; } + /** + * @return Collection + */ public function getAccessTokens(?Criteria $criteria = null): Collection { if ($criteria === null) { @@ -94,7 +101,7 @@ public function getAccessTokens(?Criteria $criteria = null): Collection return $this->accessTokens->matching($criteria); } - public function addAuthCode(OAuthAuthCode $authCode): self + public function addAuthCode(AuthCodeEntityInterface $authCode): self { if (! $this->authCodes->contains($authCode)) { $this->authCodes->add($authCode); @@ -103,7 +110,7 @@ public function addAuthCode(OAuthAuthCode $authCode): self return $this; } - public function removeAuthCode(OAuthAuthCode $authCode): self + public function removeAuthCode(AuthCodeEntityInterface $authCode): self { if ($this->authCodes->contains($authCode)) { $this->authCodes->removeElement($authCode); @@ -112,6 +119,9 @@ public function removeAuthCode(OAuthAuthCode $authCode): self return $this; } + /** + * @return Collection + */ public function getAuthCodes(?Criteria $criteria = null): Collection { if ($criteria === null) { diff --git a/src/Core/src/Setting/src/ConfigProvider.php b/src/Core/src/Setting/src/ConfigProvider.php index 9cfda07..7c72e4d 100644 --- a/src/Core/src/Setting/src/ConfigProvider.php +++ b/src/Core/src/Setting/src/ConfigProvider.php @@ -7,10 +7,35 @@ use Core\Setting\DBAL\Types\SettingIdentifierEnumType; use Core\Setting\Repository\SettingRepository; use Doctrine\ORM\Mapping\Driver\AttributeDriver; +use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; +/** + * @phpstan-type DoctrineConfigType array{ + * driver: array{ + * orm_default: array{ + * drivers: array, + * }, + * SettingEntities: array{ + * class: class-string, + * cache: non-empty-string, + * paths: non-empty-string[], + * }, + * }, + * types: array, + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * } + */ class ConfigProvider { + /** + * @return array{ + * dependencies: DependenciesType, + * doctrine: DoctrineConfigType, + * } + */ public function __invoke(): array { return [ @@ -19,6 +44,9 @@ public function __invoke(): array ]; } + /** + * @return DependenciesType + */ private function getDependencies(): array { return [ @@ -28,6 +56,9 @@ private function getDependencies(): array ]; } + /** + * @return DoctrineConfigType + */ private function getDoctrineConfig(): array { return [ diff --git a/src/Core/src/Setting/src/Entity/Setting.php b/src/Core/src/Setting/src/Entity/Setting.php index bf28aa2..c24b92b 100644 --- a/src/Core/src/Setting/src/Entity/Setting.php +++ b/src/Core/src/Setting/src/Entity/Setting.php @@ -12,6 +12,7 @@ use Doctrine\ORM\Mapping as ORM; use function array_unique; +use function assert; use function json_decode; use function json_encode; @@ -27,11 +28,14 @@ class Setting extends AbstractEntity protected ?Admin $admin = null; #[ORM\Column(type: 'setting_enum', enumType: SettingIdentifierEnum::class)] - protected ?SettingIdentifierEnum $identifier = null; + protected SettingIdentifierEnum $identifier; #[ORM\Column(name: 'value', type: 'text')] protected ?string $value = null; + /** + * @param non-empty-string[] $value + */ public function __construct(Admin $admin, SettingIdentifierEnum $identifier, array $value) { parent::__construct(); @@ -67,16 +71,28 @@ public function setIdentifier(SettingIdentifierEnum $identifier): self public function getValue(): mixed { - return json_decode($this->value, true); + return json_decode((string) $this->value, true); } + /** + * @param non-empty-string[] $value + */ public function setValue(array $value): self { - $this->value = json_encode(array_unique($value)); + $value = json_encode(array_unique($value)); + assert($value !== false); + + $this->value = $value; return $this; } + /** + * @return array{ + * identifier: non-empty-string, + * value: non-empty-string[], + * } + */ public function getArrayCopy(): array { return [ diff --git a/src/Core/src/Setting/src/Enum/SettingIdentifierEnum.php b/src/Core/src/Setting/src/Enum/SettingIdentifierEnum.php index c118510..4038d93 100644 --- a/src/Core/src/Setting/src/Enum/SettingIdentifierEnum.php +++ b/src/Core/src/Setting/src/Enum/SettingIdentifierEnum.php @@ -12,6 +12,9 @@ enum SettingIdentifierEnum: string case IdentifierTableAdminListLoginsSelectedColumns = 'table_admin_list_logins_selected_columns'; case IdentifierTableUserListSelectedColumns = 'table_user_list_selected_columns'; + /** + * @return non-empty-string[] + */ public static function values(): array { return array_column(self::cases(), 'value'); diff --git a/src/Core/src/User/src/ConfigProvider.php b/src/Core/src/User/src/ConfigProvider.php index 23cec3c..6655fa7 100644 --- a/src/Core/src/User/src/ConfigProvider.php +++ b/src/Core/src/User/src/ConfigProvider.php @@ -14,11 +14,36 @@ use Core\User\Repository\UserResetPasswordRepository; use Core\User\Repository\UserRoleRepository; use Doctrine\ORM\Mapping\Driver\AttributeDriver; +use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Dot\DependencyInjection\Factory\AttributedRepositoryFactory; use Dot\DependencyInjection\Factory\AttributedServiceFactory; +/** + * @phpstan-type DoctrineConfigType array{ + * driver: array{ + * orm_default: array{ + * drivers: array, + * }, + * UserEntities: array{ + * class: class-string, + * cache: non-empty-string, + * paths: non-empty-string[], + * }, + * }, + * types: array, + * } + * @phpstan-type DependenciesType array{ + * factories: array, + * } + */ class ConfigProvider { + /** + * @return array{ + * dependencies: DependenciesType, + * doctrine: DoctrineConfigType, + * } + */ public function __invoke(): array { return [ @@ -27,6 +52,9 @@ public function __invoke(): array ]; } + /** + * @return DependenciesType + */ private function getDependencies(): array { return [ @@ -41,6 +69,9 @@ private function getDependencies(): array ]; } + /** + * @return DoctrineConfigType + */ private function getDoctrineConfig(): array { return [ diff --git a/src/Core/src/User/src/Entity/User.php b/src/Core/src/User/src/Entity/User.php index 220a0f2..e8e12d2 100644 --- a/src/Core/src/User/src/Entity/User.php +++ b/src/Core/src/User/src/Entity/User.php @@ -10,6 +10,7 @@ use Core\App\Entity\TimestampsTrait; use Core\User\Enum\UserStatusEnum; use Core\User\Repository\UserRepository; +use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -21,6 +22,11 @@ use function trim; use function uniqid; +/** + * @phpstan-import-type UserAvatarType from UserAvatar + * @phpstan-import-type UserDetailType from UserDetail + * @phpstan-import-type RoleType from RoleInterface + */ #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: 'user')] #[ORM\HasLifecycleCallbacks] @@ -35,15 +41,18 @@ class User extends AbstractEntity implements UserEntityInterface #[ORM\OneToOne(targetEntity: UserDetail::class, mappedBy: 'user', cascade: ['persist', 'remove'])] protected ?UserDetail $detail = null; + /** @var Collection */ #[ORM\OneToMany(targetEntity: UserResetPassword::class, mappedBy: 'user', cascade: ['persist', 'remove'])] protected Collection $resetPasswords; + /** @var Collection */ #[ORM\ManyToMany(targetEntity: UserRole::class)] #[ORM\JoinTable(name: 'user_roles')] #[ORM\JoinColumn(name: 'userUuid', referencedColumnName: 'uuid')] #[ORM\InverseJoinColumn(name: 'roleUuid', referencedColumnName: 'uuid')] protected Collection $roles; + /** @var non-empty-string|null $identity */ #[ORM\Column(name: 'identity', type: 'string', length: 191, unique: true)] protected ?string $identity = null; @@ -57,13 +66,15 @@ enumType: UserStatusEnum::class, )] protected UserStatusEnum $status = UserStatusEnum::Pending; + /** @var non-empty-string $hash */ #[ORM\Column(name: 'hash', type: 'string', length: 191, unique: true)] - protected ?string $hash = null; + protected string $hash; public function __construct() { parent::__construct(); + $this->hash = self::generateHash(); $this->roles = new ArrayCollection(); $this->resetPasswords = new ArrayCollection(); @@ -95,7 +106,7 @@ public function setAvatar(?UserAvatar $avatar): self return $this; } - public function getDetail(): UserDetail + public function getDetail(): ?UserDetail { return $this->detail; } @@ -117,6 +128,9 @@ public function addResetPassword(UserResetPassword $resetPassword): void $this->resetPasswords->add($resetPassword); } + /** + * @return Collection + */ public function getResetPasswords(): Collection { return $this->resetPasswords; @@ -134,6 +148,9 @@ public function removeResetPassword(UserResetPassword $resetPassword): self return $this; } + /** + * @param array $resetPasswords + */ public function setResetPasswords(array $resetPasswords): self { foreach ($resetPasswords as $resetPassword) { @@ -150,6 +167,9 @@ public function addRole(RoleInterface $role): self return $this; } + /** + * @return RoleInterface[] + */ public function getRoles(): array { return $this->roles->toArray(); @@ -167,6 +187,9 @@ public function removeRole(RoleInterface $role): self return $this; } + /** + * @param RoleInterface[] $roles + */ public function setRoles(array $roles): self { foreach ($roles as $role) { @@ -186,6 +209,9 @@ public function hasIdentity(): bool return $this->identity !== null; } + /** + * @param non-empty-string $identity + */ public function setIdentity(string $identity): self { $this->identity = $identity; @@ -222,6 +248,9 @@ public function getHash(): ?string return $this->hash; } + /** + * @param non-empty-string $hash + */ public function setHash(string $hash): self { $this->hash = $hash; @@ -231,7 +260,7 @@ public function setHash(string $hash): self public function getIdentifier(): string { - return $this->identity; + return (string) $this->identity; } public function activate(): self @@ -244,13 +273,29 @@ public function deactivate(): self return $this->setStatus(UserStatusEnum::Pending); } + /** + * @return non-empty-string + */ public static function generateHash(): string { return bin2hex(md5(uniqid())); } + public function getEmail(): string + { + if (! $this->getDetail() instanceof UserDetail) { + return ''; + } + + return trim((string) $this->getDetail()->getEmail()); + } + public function getName(): string { + if (! $this->getDetail() instanceof UserDetail) { + return ''; + } + return trim($this->getDetail()->getFirstName() . ' ' . $this->getDetail()->getLastName()); } @@ -288,16 +333,29 @@ public function hasRoles(): bool return $this->roles->count() > 0; } + /** + * @return array{ + * uuid: non-empty-string, + * avatar: UserAvatarType|null, + * detail: UserDetailType|null, + * hash: non-empty-string, + * identity: non-empty-string|null, + * status: non-empty-string, + * roles: RoleType[], + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null, + * } + */ public function getArrayCopy(): array { return [ 'uuid' => $this->uuid->toString(), 'avatar' => $this->avatar?->getArrayCopy(), - 'detail' => $this->detail->getArrayCopy(), + 'detail' => $this->detail?->getArrayCopy(), 'hash' => $this->hash, 'identity' => $this->identity, 'status' => $this->status->value, - 'roles' => array_map(fn (UserRole $role): array => $role->getArrayCopy(), $this->roles->toArray()), + 'roles' => array_map(fn (RoleInterface $role): array => $role->getArrayCopy(), $this->roles->toArray()), 'created' => $this->created, 'updated' => $this->updated, ]; diff --git a/src/Core/src/User/src/Entity/UserAvatar.php b/src/Core/src/User/src/Entity/UserAvatar.php index 4bf1559..7edf86b 100644 --- a/src/Core/src/User/src/Entity/UserAvatar.php +++ b/src/Core/src/User/src/Entity/UserAvatar.php @@ -8,8 +8,17 @@ use Core\App\Entity\TimestampsTrait; use Core\User\EventListener\UserAvatarEventListener; use Core\User\Repository\UserAvatarRepository; +use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; +/** + * @phpstan-type UserAvatarType array{ + * uuid: non-empty-string, + * url: non-empty-string|null, + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null + * } + */ #[ORM\Entity(repositoryClass: UserAvatarRepository::class)] #[ORM\Table(name: 'user_avatar')] #[ORM\HasLifecycleCallbacks] @@ -25,6 +34,7 @@ class UserAvatar extends AbstractEntity #[ORM\Column(name: 'name', type: 'string', length: 191)] protected ?string $name = null; + /** @var non-empty-string|null $url */ protected ?string $url = null; public function __construct() @@ -63,6 +73,9 @@ public function getUrl(): ?string return $this->url; } + /** + * @param non-empty-string $url + */ public function setUrl(string $url): self { $this->url = $url; @@ -70,6 +83,9 @@ public function setUrl(string $url): self return $this; } + /** + * @return UserAvatarType + */ public function getArrayCopy(): array { return [ diff --git a/src/Core/src/User/src/Entity/UserDetail.php b/src/Core/src/User/src/Entity/UserDetail.php index 024b91e..16e0d9d 100644 --- a/src/Core/src/User/src/Entity/UserDetail.php +++ b/src/Core/src/User/src/Entity/UserDetail.php @@ -7,8 +7,19 @@ use Core\App\Entity\AbstractEntity; use Core\App\Entity\TimestampsTrait; use Core\User\Repository\UserDetailRepository; +use DateTimeImmutable; use Doctrine\ORM\Mapping as ORM; +/** + * @phpstan-type UserDetailType array{ + * uuid: non-empty-string, + * firstName: non-empty-string|null, + * lastName: non-empty-string|null, + * email: non-empty-string|null, + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null, + * } + */ #[ORM\Entity(repositoryClass: UserDetailRepository::class)] #[ORM\Table(name: 'user_detail')] #[ORM\HasLifecycleCallbacks] @@ -20,12 +31,15 @@ class UserDetail extends AbstractEntity #[ORM\JoinColumn(name: 'userUuid', referencedColumnName: 'uuid')] protected ?User $user = null; + /** @var non-empty-string|null $firstName */ #[ORM\Column(name: 'firstName', type: 'string', length: 191, nullable: true)] protected ?string $firstName = null; + /** @var non-empty-string|null $lastName */ #[ORM\Column(name: 'lastName', type: 'string', length: 191, nullable: true)] protected ?string $lastName = null; + /** @var non-empty-string|null $email */ #[ORM\Column(name: 'email', type: 'string', length: 191)] protected ?string $email = null; @@ -53,6 +67,9 @@ public function getFirstName(): ?string return $this->firstName; } + /** + * @param non-empty-string|null $firstName + */ public function setFirstName(?string $firstName): self { $this->firstName = $firstName; @@ -65,6 +82,9 @@ public function getLastName(): ?string return $this->lastName; } + /** + * @param non-empty-string|null $lastName + */ public function setLastName(?string $lastName): self { $this->lastName = $lastName; @@ -82,6 +102,9 @@ public function hasEmail(): bool return $this->email !== null && $this->email !== ''; } + /** + * @param non-empty-string $email + */ public function setEmail(string $email): self { $this->email = $email; @@ -89,6 +112,9 @@ public function setEmail(string $email): self return $this; } + /** + * @return UserDetailType + */ public function getArrayCopy(): array { return [ diff --git a/src/Core/src/User/src/Entity/UserResetPassword.php b/src/Core/src/User/src/Entity/UserResetPassword.php index 63c66bd..41ab84a 100644 --- a/src/Core/src/User/src/Entity/UserResetPassword.php +++ b/src/Core/src/User/src/Entity/UserResetPassword.php @@ -26,10 +26,11 @@ class UserResetPassword extends AbstractEntity protected ?User $user = null; #[ORM\Column(name: 'expires', type: 'datetime_immutable')] - protected ?DateTimeImmutable $expires = null; + protected DateTimeImmutable $expires; + /** @var non-empty-string $hash */ #[ORM\Column(name: 'hash', type: 'string', length: 191, unique: true)] - protected ?string $hash = null; + protected string $hash; #[ORM\Column( type: 'user_reset_password_status_enum', @@ -46,6 +47,7 @@ public function __construct() $this->expires = DateTimeImmutable::createFromMutable( (new DateTime())->add(new DateInterval('P1D')) ); + $this->hash = User::generateHash(); } public function getUser(): ?User @@ -60,7 +62,7 @@ public function setUser(User $user): self return $this; } - public function getExpires(): ?DateTimeImmutable + public function getExpires(): DateTimeImmutable { return $this->expires; } @@ -72,11 +74,14 @@ public function setExpires(DateTimeImmutable $expires): self return $this; } - public function getHash(): ?string + public function getHash(): string { return $this->hash; } + /** + * @param non-empty-string $hash + */ public function setHash(string $hash): self { $this->hash = $hash; @@ -117,6 +122,16 @@ public function markAsCompleted(): self return $this; } + /** + * @return array{ + * uuid: non-empty-string, + * expires: DateTimeImmutable, + * hash: non-empty-string, + * status: 'completed'|'requested', + * created: DateTimeImmutable, + * updated: DateTimeImmutable|null + * } + */ public function getArrayCopy(): array { return [ diff --git a/src/Core/src/User/src/Entity/UserRole.php b/src/Core/src/User/src/Entity/UserRole.php index 719614a..23e1888 100644 --- a/src/Core/src/User/src/Entity/UserRole.php +++ b/src/Core/src/User/src/Entity/UserRole.php @@ -12,6 +12,9 @@ use Core\User\Repository\UserRoleRepository; use Doctrine\ORM\Mapping as ORM; +/** + * @phpstan-import-type RoleType from RoleInterface + */ #[ORM\Entity(repositoryClass: UserRoleRepository::class)] #[ORM\Table(name: 'user_role')] #[ORM\HasLifecycleCallbacks] @@ -35,7 +38,7 @@ public function __construct() $this->created(); } - public function getName(): ?UserRoleEnum + public function getName(): UserRoleEnum { return $this->name; } @@ -50,6 +53,9 @@ public function setName(BackedEnum $name): RoleInterface return $this; } + /** + * @return RoleType + */ public function getArrayCopy(): array { return [ diff --git a/src/Core/src/User/src/Enum/UserRoleEnum.php b/src/Core/src/User/src/Enum/UserRoleEnum.php index b08516b..b3c6b23 100644 --- a/src/Core/src/User/src/Enum/UserRoleEnum.php +++ b/src/Core/src/User/src/Enum/UserRoleEnum.php @@ -11,6 +11,9 @@ enum UserRoleEnum: string case Guest = 'guest'; case User = 'user'; + /** + * @return array + */ public static function validCases(): array { return array_filter(self::cases(), fn (self $value) => $value !== self::Guest); diff --git a/src/Core/src/User/src/Enum/UserStatusEnum.php b/src/Core/src/User/src/Enum/UserStatusEnum.php index 9ed5c05..7bac8ea 100644 --- a/src/Core/src/User/src/Enum/UserStatusEnum.php +++ b/src/Core/src/User/src/Enum/UserStatusEnum.php @@ -14,16 +14,25 @@ enum UserStatusEnum: string case Pending = 'pending'; case Deleted = 'deleted'; + /** + * @return non-empty-string[] + */ public static function values(): array { return array_column(self::validCases(), 'value'); } + /** + * @return self[] + */ public static function validCases(): array { return array_filter(self::cases(), fn (self $enum) => $enum !== self::Deleted); } + /** + * @return array + */ public static function toArray(): array { return array_reduce(self::validCases(), function (array $collector, self $enum): array { diff --git a/src/Core/src/User/src/EventListener/UserAvatarEventListener.php b/src/Core/src/User/src/EventListener/UserAvatarEventListener.php index 9f19947..0c7b06e 100644 --- a/src/Core/src/User/src/EventListener/UserAvatarEventListener.php +++ b/src/Core/src/User/src/EventListener/UserAvatarEventListener.php @@ -4,14 +4,19 @@ namespace Core\User\EventListener; +use Core\User\Entity\User; use Core\User\Entity\UserAvatar; use Dot\DependencyInjection\Attribute\Inject; +use function assert; use function rtrim; use function sprintf; class UserAvatarEventListener { + /** + * @param array $config + */ #[Inject( 'config', )] @@ -37,6 +42,8 @@ public function postUpdate(UserAvatar $avatar): void private function setAvatarUrl(UserAvatar $avatar): void { + assert($avatar->getUser() instanceof User); + $avatar->setUrl( sprintf( '%s/%s/%s', diff --git a/src/Core/src/User/src/Repository/UserRepository.php b/src/Core/src/User/src/Repository/UserRepository.php index f46f0f9..ed7bb6c 100644 --- a/src/Core/src/User/src/Repository/UserRepository.php +++ b/src/Core/src/User/src/Repository/UserRepository.php @@ -25,6 +25,10 @@ #[Entity(name: User::class)] class UserRepository extends AbstractRepository implements UserRepositoryInterface { + /** + * @param array $params + * @param array $filters + */ public function getUsers(array $params = [], array $filters = []): QueryBuilder { $queryBuilder = $this diff --git a/src/Core/src/User/src/Repository/UserRoleRepository.php b/src/Core/src/User/src/Repository/UserRoleRepository.php index dbdf38b..aca525a 100644 --- a/src/Core/src/User/src/Repository/UserRoleRepository.php +++ b/src/Core/src/User/src/Repository/UserRoleRepository.php @@ -16,6 +16,10 @@ #[Entity(name: UserRole::class)] class UserRoleRepository extends AbstractRepository { + /** + * @param array $params + * @param array $filters + */ public function getUserRoles(array $params = [], array $filters = []): QueryBuilder { $queryBuilder = $this diff --git a/src/Core/src/User/src/UserIdentity.php b/src/Core/src/User/src/UserIdentity.php index e9bf72a..be69b69 100644 --- a/src/Core/src/User/src/UserIdentity.php +++ b/src/Core/src/User/src/UserIdentity.php @@ -8,11 +8,18 @@ class UserIdentity implements UserInterface { + /** @var non-empty-string $identity */ protected string $identity; - /** @var array $roles */ + /** @var array $roles */ protected array $roles; + /** @var array $details */ protected array $details; + /** + * @param non-empty-string $identity + * @param array $roles + * @param array $details + */ public function __construct(string $identity, array $roles = [], array $details = []) { $this->identity = $identity; @@ -43,6 +50,9 @@ public function getDetails(): array return $this->details; } + /** + * @param array $roles + */ public function setRoles(array $roles): void { $this->roles = $roles; diff --git a/test/Unit/Admin/Entity/AdminLoginTest.php b/test/Unit/Admin/Entity/AdminLoginTest.php index 99bbef0..f02e913 100644 --- a/test/Unit/Admin/Entity/AdminLoginTest.php +++ b/test/Unit/Admin/Entity/AdminLoginTest.php @@ -77,7 +77,7 @@ public function testAccessors(): void $this->assertSame(AdminLogin::class, $adminLogin::class); $this->assertSame('test', $adminLogin->getDeviceModel()); - $this->assertNull($adminLogin->getIsMobile()); + $this->assertSame(YesNoEnum::No, $adminLogin->getIsMobile()); $adminLogin = $adminLogin->setIsMobile(YesNoEnum::Yes); $this->assertSame(AdminLogin::class, $adminLogin::class); $this->assertSame('yes', $adminLogin->getIsMobile()->value); @@ -117,7 +117,7 @@ public function testAccessors(): void $this->assertSame(AdminLogin::class, $adminLogin::class); $this->assertSame('test', $adminLogin->getClientVersion()); - $this->assertNull($adminLogin->getLoginStatus()); + $this->assertSame(SuccessFailureEnum::Fail, $adminLogin->getLoginStatus()); $adminLogin = $adminLogin->setLoginStatus(SuccessFailureEnum::Success); $this->assertSame(AdminLogin::class, $adminLogin::class); $this->assertSame('success', $adminLogin->getLoginStatus()->value); diff --git a/test/Unit/Admin/Entity/AdminTest.php b/test/Unit/Admin/Entity/AdminTest.php index c535dfe..fe3a5f2 100644 --- a/test/Unit/Admin/Entity/AdminTest.php +++ b/test/Unit/Admin/Entity/AdminTest.php @@ -10,7 +10,6 @@ use Core\Admin\Enum\AdminRoleEnum; use Core\Admin\Enum\AdminStatusEnum; use Core\Admin\Repository\AdminRepository; -use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Table; use Ramsey\Uuid\UuidInterface; @@ -95,9 +94,7 @@ public function testAccessors(): void $this->assertSame(Admin::class, $admin::class); $this->assertIsArray($admin->getRoles()); $this->assertEmpty($admin->getRoles()); - $roles = new ArrayCollection(); - $roles->add($this->default['roles'][0]); - $admin = $admin->setRoles($roles); + $admin = $admin->setRoles($this->default['roles']); $this->assertSame(Admin::class, $admin::class); $this->assertIsArray($admin->getRoles()); $this->assertCount(1, $admin->getRoles());