mirror of
https://gitlab.com/izyim/prototypes/ddd-example.git
synced 2026-08-19 12:34:08 +00:00
Create User Zone
This commit is contained in:
+5
-1
@@ -9,7 +9,8 @@
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"laravel/framework": "^9.19",
|
||||
"laravel/sanctum": "^3.0",
|
||||
"laravel/tinker": "^2.7"
|
||||
"laravel/tinker": "^2.7",
|
||||
"tymon/jwt-auth": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.9.1",
|
||||
@@ -21,6 +22,9 @@
|
||||
"spatie/laravel-ignition": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Common/Infrastructure/helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Src\\": "src/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
|
||||
+6
-1
@@ -182,6 +182,11 @@ return [
|
||||
Illuminate\Validation\ValidationServiceProvider::class,
|
||||
Illuminate\View\ViewServiceProvider::class,
|
||||
|
||||
/*
|
||||
* Domain Service Providers...
|
||||
*/
|
||||
\Src\Zone\User\Application\Providers\UserServiceProvider::class,
|
||||
|
||||
/*
|
||||
* Package Service Providers...
|
||||
*/
|
||||
@@ -190,7 +195,7 @@ return [
|
||||
* Application Service Providers...
|
||||
*/
|
||||
\Src\Common\Infrastructure\Laravel\Providers\AppServiceProvider::class,
|
||||
// \Src\Auth\Application\Providers\AuthServiceProvider::class,
|
||||
\Src\Auth\Application\Providers\AuthServiceProvider::class,
|
||||
// Src\Common\Infrastructure\Laravel\Providers\BroadcastServiceProvider::class,
|
||||
\Src\Common\Infrastructure\Laravel\Providers\EventServiceProvider::class,
|
||||
\Src\Common\Infrastructure\Laravel\Providers\RouteServiceProvider::class,
|
||||
|
||||
+7
-2
@@ -14,7 +14,7 @@ return [
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => 'web',
|
||||
'guard' => 'internal-api',
|
||||
'passwords' => 'users',
|
||||
],
|
||||
|
||||
@@ -40,6 +40,11 @@ return [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
'internal-api' => [
|
||||
'driver' => 'jwt',
|
||||
'provider' => 'users',
|
||||
'hash' => false,
|
||||
]
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -62,7 +67,7 @@ return [
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => App\Models\User::class,
|
||||
'model' => \Src\Zone\User\Infrastructure\EloquentModels\UserEloquentModel::class,
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
|
||||
@@ -16,9 +16,12 @@ return new class extends Migration
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('avatar')->nullable();
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->boolean('is_admin')->default(0);
|
||||
$table->boolean('is_active')->default(1);
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Auth\Application;
|
||||
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Src\Zone\User\Application\Mappers\UserMapper;
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
use Src\Zone\User\Infrastructure\EloquentModels\UserEloquentModel;
|
||||
use Src\Auth\Domain\AuthInterface;
|
||||
use Tymon\JWTAuth\Exceptions\JWTException;
|
||||
use Tymon\JWTAuth\Facades\JWTAuth as TymonJWTAuth;
|
||||
|
||||
class JWTAuth implements AuthInterface
|
||||
{
|
||||
|
||||
public function login(array $credentials): string
|
||||
{
|
||||
$user = UserEloquentModel::query()->where('email', $credentials['email'])->first();
|
||||
if (!$user || !$user->is_active) {
|
||||
throw new AuthenticationException();
|
||||
} elseif (!$token = auth()->attempt($credentials)) {
|
||||
throw new AuthenticationException();
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
auth()->logout();
|
||||
}
|
||||
|
||||
public function me(): User
|
||||
{
|
||||
return UserMapper::fromAuth(auth()->user());
|
||||
}
|
||||
|
||||
public function refresh(): string
|
||||
{
|
||||
try {
|
||||
return TymonJWTAuth::parseToken()->refresh();
|
||||
} catch (JWTException $e) {
|
||||
Log::error($e->getMessage());
|
||||
throw new AuthenticationException($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,10 @@ class AuthServiceProvider extends ServiceProvider
|
||||
|
||||
public function register()
|
||||
{
|
||||
//
|
||||
$this->app->bind(
|
||||
\Src\Auth\Domain\AuthInterface::class,
|
||||
\Src\Auth\Application\JWTAuth::class
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Auth\Domain;
|
||||
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
|
||||
interface AuthInterface
|
||||
{
|
||||
/**
|
||||
* @throws AuthenticationException
|
||||
*/
|
||||
public function login(array $credentials): string;
|
||||
/**
|
||||
* @throws AuthenticationException
|
||||
*/
|
||||
public function refresh(): string;
|
||||
|
||||
public function logout(): void;
|
||||
public function me(): User;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<?php
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
namespace Src\Auth\Presentation\HTTP;
|
||||
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Src\Auth\Domain\AuthInterface;
|
||||
use Src\Common\Infrastructure\Laravel\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
private AuthInterface $auth;
|
||||
|
||||
public function __construct(AuthInterface $auth)
|
||||
{
|
||||
$this->auth = $auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a JWT via given credentials.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function login(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$email = $request->get('email');
|
||||
$password = $request->get('password');
|
||||
$credentials = ['email' => strtolower($email), 'password' => $password];
|
||||
$validator = Validator::make($credentials, [
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
throw new ValidationException($validator);
|
||||
}
|
||||
$token = $this->auth->login($credentials);
|
||||
return $this->respondWithToken($token);
|
||||
} catch (ValidationException $validationException) {
|
||||
return response()->json($validationException->errors(), Response::HTTP_BAD_REQUEST);
|
||||
} catch (AuthenticationException) {
|
||||
return response()->json(['error' => 'Unauthorized'], Response::HTTP_UNAUTHORIZED );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated UserEloquentModel.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function me(): JsonResponse
|
||||
{
|
||||
return response()->json($this->auth->me()->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out (Invalidate the token).
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function logout(): JsonResponse
|
||||
{
|
||||
$this->auth->logout();
|
||||
return response()->json(['message' => 'Successfully logged out']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a token.
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function refresh(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$token = $this->auth->refresh();
|
||||
} catch (AuthenticationException $e) {
|
||||
return response()->json(['status' => $e->getMessage()], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return $this->respondWithToken($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token array structure.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return JsonResponse
|
||||
*/
|
||||
protected function respondWithToken(string $token): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'accessToken' => $token,
|
||||
'token_type' => 'bearer',
|
||||
'expires_in' => config('jwt.ttl') * 1,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Src\Auth\Presentation\HTTP\AuthController;
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'auth'
|
||||
], function () {
|
||||
Route::post('login', [AuthController::class, 'login']);
|
||||
Route::post('logout', [AuthController::class, 'logout']);
|
||||
Route::post('refresh', [AuthController::class, 'refresh']);
|
||||
Route::get('me', [AuthController::class, 'me']);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Domain;
|
||||
|
||||
class AggregateRoot
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Domain;
|
||||
|
||||
use Src\Common\Domain\Exceptions\UnauthorizedUserException;
|
||||
|
||||
interface CommandInterface
|
||||
{
|
||||
/**
|
||||
* @throws UnauthorizedUserException
|
||||
*/
|
||||
public function execute();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Domain\Exceptions;
|
||||
|
||||
class EntityNotFoundException extends \DomainException
|
||||
{
|
||||
public function __construct(string $message = 'Entity not found')
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Domain\Exceptions;
|
||||
|
||||
final class IncorrectEmailFormatException extends \DomainException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Must be a valid email');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Domain\Exceptions;
|
||||
|
||||
final class MaximumValueException extends \DomainException
|
||||
{
|
||||
public function __construct($fieldName, $value)
|
||||
{
|
||||
parent::__construct(__("The maximum value for '$fieldName' is '$value"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Domain\Exceptions;
|
||||
|
||||
final class RequiredException extends \DomainException
|
||||
{
|
||||
public function __construct($fieldName)
|
||||
{
|
||||
parent::__construct(trans('validation.required', ['attribute' => $fieldName]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Domain\Exceptions;
|
||||
|
||||
final class UnauthorizedUserException extends \Exception
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('The user is not authorized to access this resource');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Domain;
|
||||
|
||||
use Src\Common\Domain\Exceptions\UnauthorizedUserException;
|
||||
|
||||
interface QueryInterface
|
||||
{
|
||||
/**
|
||||
* @throws UnauthorizedUserException
|
||||
*/
|
||||
public function handle(): mixed;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Common\Infrastructure\Laravel\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Tymon\JWTAuth\Exceptions\TokenExpiredException;
|
||||
use Tymon\JWTAuth\Exceptions\TokenInvalidException;
|
||||
use Tymon\JWTAuth\Facades\JWTAuth;
|
||||
use Tymon\JWTAuth\Http\Middleware\BaseMiddleware;
|
||||
|
||||
class JwtMiddleware extends BaseMiddleware
|
||||
{
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
try {
|
||||
if (in_array($request->decodedPath(), ['auth/login', 'auth/refresh', 'login', 'refresh']) && $request->method() == 'POST') {
|
||||
return $next($request);
|
||||
}
|
||||
JWTAuth::parseToken()->authenticate();
|
||||
} catch (Exception $e) {
|
||||
if ($e instanceof TokenInvalidException){
|
||||
return response()->json(['status' => 'Token is Invalid'], Response::HTTP_UNAUTHORIZED );
|
||||
}else if ($e instanceof TokenExpiredException){
|
||||
return response()->json(['status' => 'Token is Expired'], Response::HTTP_UNAUTHORIZED );
|
||||
}else{
|
||||
return response()->json(['status' => 'Authorization Token not found'], Response::HTTP_UNAUTHORIZED );
|
||||
}
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,12 @@ class AuthServiceProvider extends ServiceProvider
|
||||
|
||||
//
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
$this->app->bind(
|
||||
\Src\Auth\Domain\AuthInterface::class,
|
||||
\Src\Auth\Application\JWTAuth::class
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,17 @@ class RouteServiceProvider extends ServiceProvider
|
||||
$this->configureRateLimiting();
|
||||
|
||||
$this->routes(function () {
|
||||
Route::middleware('api')
|
||||
->prefix('api')
|
||||
->group(base_path('routes/api.php'));
|
||||
|
||||
Route::middleware('web')
|
||||
->group(base_path('routes/web.php'));
|
||||
|
||||
Route::middleware('api')
|
||||
->group(base_path('routes/api.php'));
|
||||
|
||||
Route::middleware('internal_api')
|
||||
->group(function() {
|
||||
require base_path('src/Auth/Presentation/HTTP/routes.php');
|
||||
require base_path('src/Zone/User/Presentation/HTTP/routes.php');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,8 +50,8 @@ class RouteServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected function configureRateLimiting()
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||
RateLimiter::for('internal_api', function (Request $request) {
|
||||
return Limit::perMinute(60)->by($request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\Exceptions;
|
||||
|
||||
final class EmailAlreadyUsedException extends \DomainException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('El email ya está en uso');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\Mappers;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Http\Request;
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Avatar;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\CompanyId;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Email;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Name;
|
||||
use Src\Zone\User\Domain\Repositories\AvatarRepositoryInterface;
|
||||
use Src\Zone\User\Infrastructure\EloquentModels\UserEloquentModel;
|
||||
|
||||
class UserMapper
|
||||
{
|
||||
public static function fromRequest(Request $request, ?int $user_id = null): User
|
||||
{
|
||||
return new User(
|
||||
id: $user_id,
|
||||
name: new Name($request->input('name')),
|
||||
email: new Email($request->input('email')),
|
||||
avatar: new Avatar($request->input('avatar')),
|
||||
is_admin: $request->input('is_admin') ?? false,
|
||||
is_active: $request->input('is_active') ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromEloquent(UserEloquentModel $userEloquent): User
|
||||
{
|
||||
$avatarRepository = app()->make(AvatarRepositoryInterface::class);
|
||||
return new User(
|
||||
id: $userEloquent->id,
|
||||
name: new Name($userEloquent->name),
|
||||
email: new Email($userEloquent->email),
|
||||
avatar: $avatarRepository->retrieveAvatarFile(new Avatar($userEloquent->avatar)),
|
||||
is_admin: $userEloquent->is_admin,
|
||||
is_active: $userEloquent->is_active
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromAuth(Authenticatable $userEloquent): User
|
||||
{
|
||||
$avatarRepository = app()->make(AvatarRepositoryInterface::class);
|
||||
return new User(
|
||||
id: $userEloquent->id,
|
||||
name: new Name($userEloquent->name),
|
||||
email: new Email($userEloquent->email),
|
||||
avatar: $avatarRepository->retrieveAvatarFile(new Avatar($userEloquent->avatar)),
|
||||
is_admin: $userEloquent->is_admin,
|
||||
is_active: $userEloquent->is_active
|
||||
);
|
||||
}
|
||||
|
||||
public static function toEloquent(User $user): UserEloquentModel
|
||||
{
|
||||
$userEloquent = new UserEloquentModel();
|
||||
if ($user->id) {
|
||||
$userEloquent = UserEloquentModel::query()->find($user->id);
|
||||
}
|
||||
$userEloquent->name = $user->name;
|
||||
$userEloquent->email = $user->email;
|
||||
$userEloquent->avatar = $user->avatar;
|
||||
$userEloquent->is_admin = $user->is_admin;
|
||||
$userEloquent->is_active = $user->is_active;
|
||||
return $userEloquent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\Providers;
|
||||
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class UserServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register()
|
||||
{
|
||||
$this->app->bind(
|
||||
\Src\Zone\User\Domain\Repositories\UserRepositoryInterface::class,
|
||||
\Src\Zone\User\Application\Repositories\Eloquent\UserRepository::class
|
||||
);
|
||||
|
||||
$this->app->bind(
|
||||
\Src\Zone\User\Domain\Repositories\AvatarRepositoryInterface::class,
|
||||
\Src\Zone\User\Application\Repositories\Local\AvatarRepository::class
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\Repositories\Eloquent;
|
||||
|
||||
use Src\Zone\User\Application\Mappers\UserMapper;
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Password;
|
||||
use Src\Zone\User\Domain\Repositories\UserRepositoryInterface;
|
||||
use Src\Zone\User\Infrastructure\EloquentModels\UserEloquentModel;
|
||||
|
||||
class UserRepository implements UserRepositoryInterface
|
||||
{
|
||||
public function findAll(): array
|
||||
{
|
||||
$users = [];
|
||||
foreach (UserEloquentModel::all() as $userEloquent) {
|
||||
$users[] = UserMapper::fromEloquent($userEloquent);
|
||||
}
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function findById(string $userId): User
|
||||
{
|
||||
$userEloquent = UserEloquentModel::query()->findOrFail($userId);
|
||||
return UserMapper::fromEloquent($userEloquent);
|
||||
}
|
||||
|
||||
public function findByEmail(string $email): User
|
||||
{
|
||||
$userEloquent = UserEloquentModel::query()->where('email', $email)->firstOrFail();
|
||||
return UserMapper::fromEloquent($userEloquent);
|
||||
}
|
||||
|
||||
public function store(User $user, Password $password): User
|
||||
{
|
||||
$userEloquent = new UserEloquentModel();
|
||||
$userEloquent->fill(array_merge($user->toArray(), ['password' => $password->value()]));
|
||||
$userEloquent->save();
|
||||
|
||||
return UserMapper::fromEloquent($userEloquent);
|
||||
}
|
||||
|
||||
public function update(User $user, Password $password): void
|
||||
{
|
||||
$userArray = $user->toArray();
|
||||
if ($password->isNotEmpty()) {
|
||||
$userArray['password'] = $password->value();
|
||||
}
|
||||
$userEloquent = UserEloquentModel::query()->findOrFail($user->id);
|
||||
$userEloquent->fill($userArray);
|
||||
$userEloquent->save();
|
||||
}
|
||||
|
||||
public function delete(int $user_id): void
|
||||
{
|
||||
$userEloquent = UserEloquentModel::query()->findOrFail($user_id);
|
||||
$userEloquent->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\Repositories\Local;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Avatar;
|
||||
use Src\Zone\User\Domain\Repositories\AvatarRepositoryInterface;
|
||||
|
||||
class AvatarRepository implements AvatarRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClientInterface $guzzle = new Client
|
||||
)
|
||||
{}
|
||||
|
||||
public function getRandomAvatar($url = 'https://doodleipsum.com/300/avatar-2?shape=circle'): Avatar
|
||||
{
|
||||
$doodleIpsum = $this->guzzle->request('GET', $url);
|
||||
$mime = $doodleIpsum->getHeader('Content-Type')[0];
|
||||
$binaryImage = base64_encode($doodleIpsum->getBody()->getContents());
|
||||
return new Avatar('data:' . $mime . ';base64,' . $binaryImage);
|
||||
}
|
||||
|
||||
public function storeAvatarFile(Avatar $avatar, string $name): ?string
|
||||
{
|
||||
if ($avatar->isBinaryFile()) {
|
||||
$fileData = base64_decode(preg_replace('#^data:image/\w+;base64,#i', '', $avatar->getPath()));
|
||||
$filename = Str::snake($name) . '.jpg';
|
||||
Storage::disk('avatars')->put($filename, $fileData);
|
||||
return $filename;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function retrieveAvatarFile(Avatar $avatar): Avatar
|
||||
{
|
||||
if ($avatar->fileExists()) {
|
||||
$fileData = Storage::disk('avatars')->get($avatar->getPath());
|
||||
$avatar->setValue('data:image/' . $avatar->getExtension() . ';base64,' . base64_encode($fileData));
|
||||
}
|
||||
return $avatar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\UseCases\Commands;
|
||||
|
||||
use Src\Zone\User\Domain\Policies\UserPolicy;
|
||||
use Src\Zone\User\Domain\Repositories\UserRepositoryInterface;
|
||||
use Src\Common\Domain\CommandInterface;
|
||||
|
||||
class DestroyUserCommand implements CommandInterface
|
||||
{
|
||||
private UserRepositoryInterface $repository;
|
||||
private UserPolicy $policy;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $id
|
||||
)
|
||||
{
|
||||
$this->repository = app()->make(UserRepositoryInterface::class);
|
||||
$this->policy = new UserPolicy();
|
||||
}
|
||||
|
||||
public function execute(): void
|
||||
{
|
||||
authorize('delete', $this->policy);
|
||||
$this->repository->delete($this->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\UseCases\Commands;
|
||||
|
||||
use Src\Zone\User\Domain\Repositories\AvatarRepositoryInterface;
|
||||
use Src\Common\Domain\CommandInterface;
|
||||
|
||||
class GetRandomAvatarCommand implements CommandInterface
|
||||
{
|
||||
private AvatarRepositoryInterface $avatarRepository;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->avatarRepository = app()->make(AvatarRepositoryInterface::class);
|
||||
}
|
||||
|
||||
public function execute(): ?string
|
||||
{
|
||||
return $this->avatarRepository->getRandomAvatar()->getPath();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\UseCases\Commands;
|
||||
|
||||
use Src\Zone\User\Application\Exceptions\EmailAlreadyUsedException;
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Password;
|
||||
use Src\Zone\User\Domain\Policies\UserPolicy;
|
||||
use Src\Zone\User\Domain\Repositories\AvatarRepositoryInterface;
|
||||
use Src\Zone\User\Domain\Repositories\UserRepositoryInterface;
|
||||
use Src\Zone\User\Infrastructure\EloquentModels\UserEloquentModel;
|
||||
use Src\Common\Domain\CommandInterface;
|
||||
|
||||
class StoreUserCommand implements CommandInterface
|
||||
{
|
||||
private UserRepositoryInterface $repository;
|
||||
private AvatarRepositoryInterface $avatarRepository;
|
||||
private UserPolicy $policy;
|
||||
|
||||
public function __construct(
|
||||
private readonly User $user,
|
||||
private readonly Password $password
|
||||
)
|
||||
{
|
||||
$this->repository = app()->make(UserRepositoryInterface::class);
|
||||
$this->avatarRepository = app()->make(AvatarRepositoryInterface::class);
|
||||
$this->policy = new UserPolicy();
|
||||
}
|
||||
|
||||
public function execute(): User
|
||||
{
|
||||
authorize('store', $this->policy);
|
||||
if (UserEloquentModel::query()->where('email', $this->user->email)->exists()) {
|
||||
throw new EmailAlreadyUsedException();
|
||||
}
|
||||
|
||||
$avatar = $this->user->avatar;
|
||||
if ($avatar->isBinaryFile()) {
|
||||
$filename = $this->avatarRepository->storeAvatarFile($avatar, $this->user->name);
|
||||
$this->user->setAvatar($filename);
|
||||
}
|
||||
|
||||
return $this->repository->store($this->user, $this->password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\UseCases\Commands;
|
||||
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Password;
|
||||
use Src\Zone\User\Domain\Policies\UserPolicy;
|
||||
use Src\Zone\User\Domain\Repositories\AvatarRepositoryInterface;
|
||||
use Src\Zone\User\Domain\Repositories\UserRepositoryInterface;
|
||||
use Src\Common\Domain\CommandInterface;
|
||||
|
||||
class UpdateUserCommand implements CommandInterface
|
||||
{
|
||||
private UserRepositoryInterface $repository;
|
||||
private AvatarRepositoryInterface $avatarRepository;
|
||||
private UserPolicy $policy;
|
||||
|
||||
public function __construct(
|
||||
private readonly User $user,
|
||||
private readonly Password $password
|
||||
)
|
||||
{
|
||||
$this->repository = app()->make(UserRepositoryInterface::class);
|
||||
$this->avatarRepository = app()->make(AvatarRepositoryInterface::class);
|
||||
$this->policy = new UserPolicy();
|
||||
}
|
||||
|
||||
public function execute(): void
|
||||
{
|
||||
authorize('update', $this->policy, ['user' => $this->user]);
|
||||
// if (UserEloquentModel::query()->where('email', $this->user->email)->exists()) {
|
||||
// throw new EmailAlreadyUsedException();
|
||||
// }
|
||||
|
||||
$avatar = $this->user->avatar;
|
||||
if ($avatar->isBinaryFile()) {
|
||||
$filename = $this->avatarRepository->storeAvatarFile($avatar, $this->user->name);
|
||||
$this->user->setAvatar($filename);
|
||||
}
|
||||
|
||||
$this->repository->update($this->user, $this->password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\UseCases\Queries;
|
||||
|
||||
use Src\Zone\User\Domain\Policies\UserPolicy;
|
||||
use Src\Zone\User\Domain\Repositories\UserRepositoryInterface;
|
||||
use Src\Common\Domain\QueryInterface;
|
||||
|
||||
class FindAllUsersQuery implements QueryInterface
|
||||
{
|
||||
private UserRepositoryInterface $repository;
|
||||
private UserPolicy $policy;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->repository = app()->make(UserRepositoryInterface::class);
|
||||
$this->policy = new UserPolicy();
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
{
|
||||
authorize('findAll', $this->policy);
|
||||
return $this->repository->findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\UseCases\Queries;
|
||||
|
||||
use Src\Zone\User\Domain\Policies\UserPolicy;
|
||||
use Src\Zone\User\Domain\Repositories\UserRepositoryInterface;
|
||||
use Src\Common\Domain\QueryInterface;
|
||||
|
||||
class FindUserByEmailQuery implements QueryInterface
|
||||
{
|
||||
private UserRepositoryInterface $repository;
|
||||
private UserPolicy $policy;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $email
|
||||
)
|
||||
{
|
||||
$this->repository = app()->make(UserRepositoryInterface::class);
|
||||
$this->policy = new UserPolicy();
|
||||
}
|
||||
|
||||
public function handle(): array
|
||||
{
|
||||
authorize('findByEmail', $this->policy);
|
||||
return $this->repository->findByEmail($this->email)->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Application\UseCases\Queries;
|
||||
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
use Src\Zone\User\Domain\Policies\UserPolicy;
|
||||
use Src\Zone\User\Domain\Repositories\UserRepositoryInterface;
|
||||
use Src\Common\Domain\QueryInterface;
|
||||
|
||||
class FindUserByIdQuery implements QueryInterface
|
||||
{
|
||||
private UserRepositoryInterface $repository;
|
||||
private UserPolicy $policy;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $id
|
||||
)
|
||||
{
|
||||
$this->repository = app()->make(UserRepositoryInterface::class);
|
||||
$this->policy = new UserPolicy();
|
||||
}
|
||||
|
||||
public function handle(): User
|
||||
{
|
||||
authorize('findById', $this->policy);
|
||||
return $this->repository->findById($this->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Domain\Exceptions;
|
||||
|
||||
final class PasswordTooShortException extends \DomainException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('The password needs to be at least 8 characters long');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Domain\Exceptions;
|
||||
|
||||
final class PasswordsDoNotMatchException extends \DomainException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('Passwords do not match');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Domain\Factories;
|
||||
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Avatar;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Email;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\CompanyId;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Name;
|
||||
|
||||
class UserFactory
|
||||
{
|
||||
public static function new(array $attributes = null): User
|
||||
{
|
||||
$attributes = $attributes ?: [];
|
||||
|
||||
$defaults = [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->safeEmail(),
|
||||
'avatar' => null,
|
||||
'is_admin' => true,
|
||||
'is_active' => true,
|
||||
];
|
||||
|
||||
$attributes = array_replace($defaults, $attributes);
|
||||
|
||||
return (new User(
|
||||
id: null,
|
||||
name: new Name($attributes['name']),
|
||||
email: new Email($attributes['email']),
|
||||
avatar: new Avatar($attributes['avatar']),
|
||||
is_admin: $attributes['is_admin'],
|
||||
is_active: $attributes['is_active']
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Src\Zone\User\Domain\Model;
|
||||
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Avatar;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Email;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Name;
|
||||
use Src\Common\Domain\AggregateRoot;
|
||||
|
||||
class User extends AggregateRoot implements \JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?int $id,
|
||||
public readonly Name $name,
|
||||
public readonly Email $email,
|
||||
public Avatar $avatar,
|
||||
public readonly bool $is_admin = false,
|
||||
public readonly bool $is_active = true
|
||||
) {}
|
||||
|
||||
public function setAvatar($avatar): void
|
||||
{
|
||||
$this->avatar = new Avatar($avatar);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'avatar' => $this->avatar,
|
||||
'is_admin' => $this->is_admin,
|
||||
'is_active' => $this->is_active,
|
||||
];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Domain\Model\ValueObjects;
|
||||
|
||||
final class Avatar implements \JsonSerializable
|
||||
{
|
||||
protected ?string $avatar;
|
||||
|
||||
public function __construct(?string $avatar)
|
||||
{
|
||||
$this->avatar = $avatar;
|
||||
}
|
||||
|
||||
public static function fromString(?string $avatar): self
|
||||
{
|
||||
return new self($avatar);
|
||||
}
|
||||
|
||||
public function setValue(?string $avatar): void
|
||||
{
|
||||
$this->avatar = $avatar;
|
||||
}
|
||||
|
||||
public function getPath(): ?string
|
||||
{
|
||||
return $this->avatar ?? '';
|
||||
}
|
||||
|
||||
public function getExtension(): ?string
|
||||
{
|
||||
return pathinfo(storage_path('app/avatars/' . $this->avatar), PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
public function isNull(): bool
|
||||
{
|
||||
return $this->avatar === null;
|
||||
}
|
||||
|
||||
public function isBinaryFile(): bool
|
||||
{
|
||||
return !$this->isNull() && str_starts_with($this->avatar, 'data:image');
|
||||
}
|
||||
|
||||
public function fileExists(): bool
|
||||
{
|
||||
return $this->avatar && file_exists(storage_path('app/avatars/' . $this->avatar));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getPath() ?? '';
|
||||
}
|
||||
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->__toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Src\Zone\User\Domain\Model\ValueObjects;
|
||||
|
||||
use Src\Common\Domain\Exceptions\IncorrectEmailFormatException;
|
||||
use Src\Common\Domain\Exceptions\RequiredException;
|
||||
|
||||
final class Email implements \JsonSerializable
|
||||
{
|
||||
private string $email;
|
||||
|
||||
public function __construct(?string $email, $isOptional = false)
|
||||
{
|
||||
if (!$email && !$isOptional) {
|
||||
throw new RequiredException('email');
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new IncorrectEmailFormatException();
|
||||
}
|
||||
|
||||
$this->email = $email;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Src\Zone\User\Domain\Model\ValueObjects;
|
||||
|
||||
use Src\Common\Domain\Exceptions\RequiredException;
|
||||
|
||||
final class Name implements \JsonSerializable
|
||||
{
|
||||
private string $name;
|
||||
|
||||
public function __construct(?string $name)
|
||||
{
|
||||
|
||||
if (!$name) {
|
||||
throw new RequiredException('nombre');
|
||||
}
|
||||
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Src\Zone\User\Domain\Model\ValueObjects;
|
||||
|
||||
use Src\Zone\User\Domain\Exceptions\PasswordsDoNotMatchException;
|
||||
use Src\Zone\User\Domain\Exceptions\PasswordTooShortException;
|
||||
|
||||
final class Password
|
||||
{
|
||||
private ?string $password;
|
||||
|
||||
public function __construct(?string $password, ?string $confirmation)
|
||||
{
|
||||
if ($password && strlen($password) < 8) {
|
||||
throw new PasswordTooShortException();
|
||||
}
|
||||
|
||||
if ($password !== $confirmation) {
|
||||
throw new PasswordsDoNotMatchException();
|
||||
}
|
||||
|
||||
$this->password = $password;
|
||||
}
|
||||
|
||||
public static function fromString(string $password, string $confirmation): self
|
||||
{
|
||||
return new self($password, $confirmation);
|
||||
}
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function isNotEmpty(): bool
|
||||
{
|
||||
return $this->password !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Domain\Policies;
|
||||
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
|
||||
class UserPolicy
|
||||
{
|
||||
public function findAll(): bool
|
||||
{
|
||||
return auth()->user()?->is_admin ?? false;
|
||||
}
|
||||
|
||||
public function findById(): bool
|
||||
{
|
||||
return auth()->user()?->is_admin ?? false;
|
||||
}
|
||||
|
||||
public function findByEmail(): bool
|
||||
{
|
||||
return auth()->user()?->is_admin ?? false;
|
||||
}
|
||||
|
||||
public function store(): bool
|
||||
{
|
||||
return auth()->user()?->is_admin ?? false;
|
||||
}
|
||||
|
||||
public function update(User $user): bool
|
||||
{
|
||||
return auth()->user()?->is_admin || auth()->user()?->id == $user->id;
|
||||
}
|
||||
|
||||
public function delete(): bool
|
||||
{
|
||||
return auth()->user()?->is_admin ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Domain\Repositories;
|
||||
|
||||
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Avatar;
|
||||
|
||||
interface AvatarRepositoryInterface
|
||||
{
|
||||
public function getRandomAvatar(?string $url): Avatar;
|
||||
|
||||
public function storeAvatarFile(Avatar $avatar, string $name): ?string;
|
||||
|
||||
public function retrieveAvatarFile(Avatar $avatar): Avatar;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Domain\Repositories;
|
||||
|
||||
//use Src\Zone\UserEloquentModel\Infrastructure\Repositories\UserDoesNotExistException;
|
||||
|
||||
use Src\Zone\User\Domain\Model\User;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Password;
|
||||
|
||||
interface UserRepositoryInterface
|
||||
{
|
||||
public function findAll(): array;
|
||||
|
||||
public function findById(string $userId): User;
|
||||
|
||||
public function findByEmail(string $email): User;
|
||||
|
||||
public function store(User $user, Password $password): User;
|
||||
|
||||
public function update(User $user, Password $password): void;
|
||||
|
||||
public function delete(int $user_id): void;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Infrastructure\EloquentModels\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
|
||||
class PasswordCast implements CastsAttributes
|
||||
{
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get($model, string $key, $value, array $attributes)
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function set($model, string $key, $value, array $attributes)
|
||||
{
|
||||
return bcrypt($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Infrastructure\EloquentModels;
|
||||
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Src\Zone\User\Infrastructure\EloquentModels\Casts\PasswordCast;
|
||||
use Tymon\JWTAuth\Contracts\JWTSubject;
|
||||
|
||||
class UserEloquentModel extends Authenticatable implements JWTSubject
|
||||
{
|
||||
use HasApiTokens, Notifiable;
|
||||
|
||||
protected $table = 'users';
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'avatar',
|
||||
'password',
|
||||
'is_admin',
|
||||
'is_active'
|
||||
];
|
||||
|
||||
public array $rules = [
|
||||
'name' => 'required',
|
||||
'email' => 'required',
|
||||
'avatar' => 'nullable',
|
||||
'password' => 'confirmed|min:8|nullable',
|
||||
'is_admin' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'is_admin' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'avatar' => 'string',
|
||||
'password' => PasswordCast::class
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the identifier that will be stored in the subject claim of the JWT.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getJWTIdentifier()
|
||||
{
|
||||
return $this->getKey();
|
||||
}
|
||||
/**
|
||||
* Return a key value array, containing any custom claims to be added to the JWT.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getJWTCustomClaims()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Presentation\HTTP;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Src\Zone\User\Application\UseCases\Commands\GetRandomAvatarCommand;
|
||||
use Src\Common\Domain\Exceptions\UnauthorizedUserException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class GetRandomAvatarController
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json((new GetRandomAvatarCommand())->execute());
|
||||
} catch (UnauthorizedUserException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Src\Zone\User\Presentation\HTTP;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Src\Zone\User\Application\Mappers\UserMapper;
|
||||
use Src\Zone\User\Application\UseCases\Commands\DestroyUserCommand;
|
||||
use Src\Zone\User\Application\UseCases\Commands\StoreUserCommand;
|
||||
use Src\Zone\User\Application\UseCases\Commands\UpdateUserCommand;
|
||||
use Src\Zone\User\Application\UseCases\Queries\FindAllUsersQuery;
|
||||
use Src\Zone\User\Application\UseCases\Queries\FindUserByIdQuery;
|
||||
use Src\Zone\User\Domain\Model\ValueObjects\Password;
|
||||
use Src\Common\Domain\Exceptions\UnauthorizedUserException;
|
||||
use Src\Common\Infrastructure\Laravel\Controller;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json((new FindAllUsersQuery())->handle());
|
||||
} catch (UnauthorizedUserException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json((new FindUserByIdQuery($id))->handle());
|
||||
} catch (UnauthorizedUserException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$userData = UserMapper::fromRequest($request);
|
||||
$password = new Password($request->input('password'), $request->input('password_confirmation'));
|
||||
$user = (new StoreUserCommand($userData, $password))->execute();
|
||||
return response()->json($user->toArray(), Response::HTTP_CREATED);
|
||||
} catch (\DomainException $domainException) {
|
||||
return response()->json(['error' => $domainException->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (UnauthorizedUserException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(int $user_id, Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$user = UserMapper::fromRequest($request, $user_id);
|
||||
$password = new Password($request->input('password'), $request->input('password_confirmation'));
|
||||
(new UpdateUserCommand($user, $password))->execute();
|
||||
return response()->json($user->toArray(), Response::HTTP_OK);
|
||||
} catch (\DomainException $domainException) {
|
||||
return response()->json(['error' => $domainException->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (UnauthorizedUserException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(int $user_id): JsonResponse
|
||||
{
|
||||
try {
|
||||
(new DestroyUserCommand($user_id))->execute();
|
||||
return response()->json(null, Response::HTTP_NO_CONTENT);
|
||||
} catch (UnauthorizedUserException $e) {
|
||||
return response()->json(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Src\Zone\User\Presentation\HTTP\GetRandomAvatarController;
|
||||
use Src\Zone\User\Presentation\HTTP\UserController;
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'user'
|
||||
], function () {
|
||||
Route::get('random-avatar', GetRandomAvatarController::class);
|
||||
|
||||
Route::get('index', [UserController::class, 'index']);
|
||||
Route::get('{id}', [UserController::class, 'show']);
|
||||
Route::post('', [UserController::class, 'store']);
|
||||
Route::put('{id}', [UserController::class, 'update']);
|
||||
Route::delete('{id}', [UserController::class, 'destroy']);
|
||||
});
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Src\Zone\User\Application\Mappers\UserMapper;
|
||||
use Src\Zone\User\Domain\Factories\UserFactory;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Tests\TestCase;
|
||||
use Tests\Support\WithLogin;
|
||||
|
||||
class UserTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase, WithLogin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->user_uri = '/user';
|
||||
$this->index_uri = $this->user_uri . '/index';
|
||||
$this->random_avatar_uri = $this->user_uri . '/random-avatar';
|
||||
$this->adminToken = $this->newLoggedAdmin()['token'];
|
||||
$this->userData = $this->newLoggedUser();
|
||||
$this->userToken = $this->userData['token'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function admin_can_retrieve_all_users()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->get($this->index_uri)
|
||||
->assertStatus(Response::HTTP_OK)
|
||||
->assertJsonCount($numberUsers + 2); // +2 because of the admin and user
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_retrieve_all_users()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken])
|
||||
->get($this->index_uri)
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED)
|
||||
->assertSee(['error' => 'The user is not authorized to access this resource']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function admin_can_get_specific_user_by_id()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
$randomUserId = $this->faker->numberBetween(1, $numberUsers);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->get($this->user_uri . '/' . $randomUserId)
|
||||
->assertStatus(Response::HTTP_OK)
|
||||
->assertJsonStructure(['id', 'name', 'email', 'avatar', 'is_admin', 'is_active']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_get_specific_user_by_id()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
$randomUserId = $this->faker->numberBetween(1, $numberUsers);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken])
|
||||
->get($this->user_uri . '/' . $randomUserId)
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED)
|
||||
->assertSee(['error' => 'The user is not authorized to access this resource']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function admin_can_create_a_user()
|
||||
{
|
||||
$password = $this->faker->password(8);
|
||||
$requestBody = [
|
||||
'name' => $this->faker->name,
|
||||
'email' => $this->faker->safeEmail,
|
||||
'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle',
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
];
|
||||
|
||||
$expectedResponse = [
|
||||
'id' => 3,
|
||||
'name' => $requestBody['name'],
|
||||
'email' => $requestBody['email'],
|
||||
'is_admin' => false,
|
||||
'is_active' => true
|
||||
];
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->post($this->user_uri, $requestBody)
|
||||
->assertStatus(Response::HTTP_CREATED)
|
||||
->assertJson($expectedResponse);
|
||||
|
||||
// Assert cannot create user with same email
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->post($this->user_uri, $requestBody)
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJson(['error' => 'El email ya está en uso']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_create_user()
|
||||
{
|
||||
$password = $this->faker->password(8);
|
||||
$requestBody = [
|
||||
'name' => $this->faker->name,
|
||||
'email' => $this->faker->safeEmail,
|
||||
'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle',
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
];
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken])
|
||||
->post($this->user_uri, $requestBody)
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED)
|
||||
->assertSee(['error' => 'The user is not authorized to access this resource']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_create_user_with_invalid_email()
|
||||
{
|
||||
$password = $this->faker->password(8);
|
||||
$requestBodyInvalidEmail = [
|
||||
'name' => $this->faker->name,
|
||||
'email' => 'invalid-email',
|
||||
'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle',
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
];
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->post($this->user_uri, $requestBodyInvalidEmail)
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJson(['error' => 'Must be a valid email']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_create_user_with_invalid_password()
|
||||
{
|
||||
$password = $this->faker->password(8);
|
||||
$requestBody = [
|
||||
'name' => $this->faker->name,
|
||||
'email' => $this->faker->safeEmail,
|
||||
'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle',
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
];
|
||||
|
||||
$requestBodyInvalidPassword = $requestBody;
|
||||
$requestBodyInvalidPassword['password'] = '1234';
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->post($this->user_uri, $requestBodyInvalidPassword)
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJson(['error' => 'The password needs to be at least 8 characters long']);
|
||||
|
||||
$requestBodyNoPasswordConfirmation = $requestBody;
|
||||
unset($requestBodyNoPasswordConfirmation['password_confirmation']);
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->post($this->user_uri, $requestBodyNoPasswordConfirmation)
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJson(['error' => 'Passwords do not match']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function admin_can_update_any_user()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
$randomUserId = $this->faker->numberBetween(1, $numberUsers);
|
||||
|
||||
$password = $this->faker->password(8);
|
||||
$requestBody = [
|
||||
'name' => $this->faker->name,
|
||||
'email' => $this->faker->safeEmail,
|
||||
'avatar' => false,
|
||||
'is_active' => false,
|
||||
'update_avatar' => true,
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
];
|
||||
|
||||
$expectedResponse = [
|
||||
'id' => $randomUserId,
|
||||
'name' => $requestBody['name'],
|
||||
'email' => $requestBody['email'],
|
||||
'avatar' => false,
|
||||
'is_admin' => false,
|
||||
'is_active' => false
|
||||
];
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->put($this->user_uri . '/' . $randomUserId, $requestBody)
|
||||
->assertStatus(Response::HTTP_OK)
|
||||
->assertJson($expectedResponse);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_update_any_user_except_itself()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
$randomUserId = $this->faker->numberBetween(4, 4 + $numberUsers);
|
||||
|
||||
// Update another user
|
||||
$password = $this->faker->password(8);
|
||||
$requestBody = [
|
||||
'name' => $this->faker->name,
|
||||
'email' => $this->faker->safeEmail,
|
||||
'avatar' => false,
|
||||
'is_active' => false,
|
||||
'update_avatar' => true,
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
];
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken])
|
||||
->put($this->user_uri . '/' . $randomUserId, $requestBody)
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED)
|
||||
->assertSee(['error' => 'The user is not authorized to access this resource']);
|
||||
|
||||
// Update itself
|
||||
$password = $this->faker->password(8);
|
||||
$requestBody = [
|
||||
'name' => $this->faker->name,
|
||||
'email' => $this->userData['email'],
|
||||
'avatar' => false,
|
||||
'is_active' => true,
|
||||
'update_avatar' => false,
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
];
|
||||
|
||||
$expectedResponse = [
|
||||
'id' => $this->userData['id'],
|
||||
'name' => $requestBody['name'],
|
||||
'email' => $requestBody['email'],
|
||||
'avatar' => $requestBody['avatar'],
|
||||
'is_active' => $requestBody['is_active']
|
||||
];
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken])
|
||||
->put($this->user_uri . '/' . $this->userData['id'], $requestBody)
|
||||
->assertStatus(Response::HTTP_OK)
|
||||
->assertJson($expectedResponse);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_update_user_with_invalid_password()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
$randomUserId = $this->faker->numberBetween(1, $numberUsers);
|
||||
|
||||
$password = $this->faker->password(8);
|
||||
$requestBody = [
|
||||
'name' => $this->faker->name,
|
||||
'email' => $this->faker->safeEmail,
|
||||
'avatar' => 'https://doodleipsum.com/300/avatar-2?shape=circle',
|
||||
'is_active' => false,
|
||||
'update_avatar' => true,
|
||||
'password' => $password,
|
||||
'password_confirmation' => $password,
|
||||
];
|
||||
|
||||
$requestBodyInvalidPassword = $requestBody;
|
||||
$requestBodyInvalidPassword['password'] = '1234';
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->put($this->user_uri . '/' . $randomUserId, $requestBodyInvalidPassword)
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJson(['error' => 'The password needs to be at least 8 characters long']);
|
||||
|
||||
$requestBodyNoPasswordConfirmation = $requestBody;
|
||||
unset($requestBodyNoPasswordConfirmation['password_confirmation']);
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->put($this->user_uri . '/' . $randomUserId, $requestBodyNoPasswordConfirmation)
|
||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||
->assertJson(['error' => 'Passwords do not match']);
|
||||
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function admin_can_delete_a_user()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
$randomUserId = $this->faker->numberBetween(1, $numberUsers);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->delete($this->user_uri . '/' . $randomUserId)
|
||||
->assertStatus(Response::HTTP_NO_CONTENT);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->get($this->user_uri . '/' . $randomUserId)
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function user_cannot_delete_a_user()
|
||||
{
|
||||
$numberUsers = $this->faker->numberBetween(1, 10);
|
||||
$this->createRandomUsers($numberUsers);
|
||||
$randomUserId = $this->faker->numberBetween(1, $numberUsers);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->userToken])
|
||||
->delete($this->user_uri . '/' . $randomUserId)
|
||||
->assertStatus(Response::HTTP_UNAUTHORIZED)
|
||||
->assertSee(['error' => 'The user is not authorized to access this resource']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_delete_user_if_does_not_exists()
|
||||
{
|
||||
$this->withHeaders(['Authorization' => 'Bearer ' . $this->adminToken])
|
||||
->delete($this->user_uri . '/' . 999)
|
||||
->assertStatus(Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
|
||||
private function createRandomUsers($usersNumber = 1): void
|
||||
{
|
||||
foreach (range(1, $usersNumber) as $_) {
|
||||
$user = UserFactory::new();
|
||||
$userEloquentModel = UserMapper::toEloquent($user);
|
||||
$userEloquentModel->password = $this->faker->password(8);
|
||||
$userEloquentModel->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DoodleAPITest extends TestCase
|
||||
{
|
||||
/** @test */
|
||||
public function get_doodle_avatar()
|
||||
{
|
||||
$url = 'https://doodleipsum.com/300/avatar-2?shape=circle';
|
||||
$response = (new Client())->get($url);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$this->assertEquals('image/png', $response->getHeader('Content-Type')[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Illuminate\Foundation\Testing\WithFaker;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Src\Zone\User\Application\Mappers\UserMapper;
|
||||
use Src\Zone\User\Domain\Factories\UserFactory;
|
||||
|
||||
trait WithLogin
|
||||
{
|
||||
use WithFaker;
|
||||
|
||||
/**
|
||||
* Create a new user instance.
|
||||
*/
|
||||
private function validCredentials(array $attributes = null): array
|
||||
{
|
||||
$password = $this->faker->password(8);
|
||||
$user = UserFactory::new($attributes);
|
||||
$userEloquentModel = UserMapper::toEloquent($user);
|
||||
$userEloquentModel->password = $password;
|
||||
$userEloquentModel->save();
|
||||
|
||||
return [
|
||||
'id' => $userEloquentModel->id,
|
||||
'email' => $user->email,
|
||||
'password' => $password,
|
||||
];
|
||||
}
|
||||
|
||||
private function newLoggedAdmin(): array
|
||||
{
|
||||
$credentials = $this->validCredentials(['is_admin' => true]);
|
||||
$response = $this->post('auth/login', $credentials);
|
||||
return ['token' => $this->getToken($response), ...$credentials];
|
||||
}
|
||||
|
||||
private function newLoggedUser(): array
|
||||
{
|
||||
$credentials = $this->validCredentials(['is_admin' => false]);
|
||||
$response = $this->post('auth/login', $credentials);
|
||||
return ['token' => $this->getToken($response), ...$credentials];
|
||||
}
|
||||
|
||||
private function getToken(TestResponse $response)
|
||||
{
|
||||
$arResponse = json_decode($response->getContent(), true);
|
||||
return $arResponse['accessToken'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user