From 298b8634ec5257830ce0d7463f5451f29c6627f7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:03:19 +0300 Subject: [PATCH] extend User entity with displayname and email confirmation Add display_name (unique) and email_confirmed_at columns plus matching getters, DTO fields, repo methods (findByDisplayName, update), and migration. Existing auth tests updated to construct User with the new params. --- backend/app/User/CreateUserDto.php | 3 + backend/app/User/EloquentUserRepository.php | 43 ++++++++++++++ backend/app/User/User.php | 18 ++++++ backend/app/User/UserModel.php | 8 +++ backend/app/User/UserRepository.php | 4 ++ .../2026_05_06_000000_create_users_table.php | 2 + backend/tests/Fakes/FakeUserRepository.php | 58 ++++++++++++++----- .../Auth/Middleware/AuthMiddlewareTest.php | 6 +- .../Unit/Auth/UseCases/CreateSessionTest.php | 2 + .../tests/Unit/Auth/UseCases/LogoutTest.php | 2 + 10 files changed, 131 insertions(+), 15 deletions(-) diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index 7c353e5..794d701 100644 --- a/backend/app/User/CreateUserDto.php +++ b/backend/app/User/CreateUserDto.php @@ -3,12 +3,15 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; +use DateTimeImmutable; readonly class CreateUserDto { public function __construct( public EmailAddress $email, + public string $displayName, public string $passwordHash, public bool $isAdmin, + public ?DateTimeImmutable $emailConfirmedAt, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index f5887ea..a4fdc67 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -3,6 +3,9 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; +use DateTimeImmutable; +use DateTimeZone; +use RuntimeException; class EloquentUserRepository implements UserRepository { @@ -10,8 +13,10 @@ class EloquentUserRepository implements UserRepository { $model = UserModel::create([ 'email' => $dto->email->value(), + 'display_name' => $dto->displayName, 'password_hash' => $dto->passwordHash, 'is_admin' => $dto->isAdmin, + 'email_confirmed_at' => $dto->emailConfirmedAt, ]); return $this->toDomain($model); @@ -31,13 +36,51 @@ class EloquentUserRepository implements UserRepository return $model === null ? null : $this->toDomain($model); } + public function findByDisplayName(string $displayName): ?User + { + $model = UserModel::where('display_name', $displayName)->first(); + + return $model === null ? null : $this->toDomain($model); + } + + /** + * @throws RuntimeException + */ + public function update(User $user): User + { + $model = UserModel::find($user->getId()); + if ($model === null) { + throw new RuntimeException( + "User with id: {$user->getId()} does not exist" + ); + } + $model->email = $user->getEmail()->value(); + $model->display_name = $user->getDisplayName(); + $model->password_hash = $user->getPasswordHash(); + $model->is_admin = $user->isAdmin(); + $model->email_confirmed_at = $user->getEmailConfirmedAt(); + $model->save(); + + return $this->toDomain($model); + } + private function toDomain(UserModel $model): User { + $confirmedAt = null; + if ($model->email_confirmed_at !== null) { + $confirmedAt = new DateTimeImmutable( + $model->email_confirmed_at->toDateTimeString(), + new DateTimeZone('UTC'), + ); + } + return new User( id: $model->id, email: new EmailAddress($model->email), + displayName: $model->display_name, passwordHash: $model->password_hash, isAdmin: $model->is_admin, + emailConfirmedAt: $confirmedAt, ); } } diff --git a/backend/app/User/User.php b/backend/app/User/User.php index 48bc7c0..ba690b8 100644 --- a/backend/app/User/User.php +++ b/backend/app/User/User.php @@ -3,14 +3,17 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; +use DateTimeImmutable; class User { public function __construct( private int $id, private EmailAddress $email, + private string $displayName, private string $passwordHash, private bool $isAdmin, + private ?DateTimeImmutable $emailConfirmedAt, ) {} public function getId(): int @@ -23,6 +26,11 @@ class User return $this->email; } + public function getDisplayName(): string + { + return $this->displayName; + } + public function getPasswordHash(): string { return $this->passwordHash; @@ -32,4 +40,14 @@ class User { return $this->isAdmin; } + + public function getEmailConfirmedAt(): ?DateTimeImmutable + { + return $this->emailConfirmedAt; + } + + public function isEmailConfirmed(): bool + { + return $this->emailConfirmedAt !== null; + } } diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index 767ac29..4de109c 100644 --- a/backend/app/User/UserModel.php +++ b/backend/app/User/UserModel.php @@ -2,21 +2,26 @@ namespace App\User; +use DateTimeImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $email + * @property string $display_name * @property string $password_hash * @property bool $is_admin + * @property ?DateTimeImmutable $email_confirmed_at * * @method static Builder|UserModel newModelQuery() * @method static Builder|UserModel newQuery() * @method static Builder|UserModel query() * @method static Builder|UserModel whereId($value) * @method static Builder|UserModel whereEmail($value) + * @method static Builder|UserModel whereDisplayName($value) * @method static Builder|UserModel whereIsAdmin($value) + * @method static Builder|UserModel whereEmailConfirmedAt($value) * * @mixin \Eloquent */ @@ -28,11 +33,14 @@ class UserModel extends Model protected $fillable = [ 'email', + 'display_name', 'password_hash', 'is_admin', + 'email_confirmed_at', ]; protected $casts = [ 'is_admin' => 'boolean', + 'email_confirmed_at' => 'immutable_datetime', ]; } diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php index 4805f3f..868371f 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -11,4 +11,8 @@ interface UserRepository public function find(int $id): ?User; public function findByEmail(EmailAddress $email): ?User; + + public function findByDisplayName(string $displayName): ?User; + + public function update(User $user): User; } diff --git a/backend/database/migrations/2026_05_06_000000_create_users_table.php b/backend/database/migrations/2026_05_06_000000_create_users_table.php index 45e4086..be0da14 100644 --- a/backend/database/migrations/2026_05_06_000000_create_users_table.php +++ b/backend/database/migrations/2026_05_06_000000_create_users_table.php @@ -11,8 +11,10 @@ return new class extends Migration Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('email')->unique(); + $table->string('display_name')->unique(); $table->string('password_hash'); $table->boolean('is_admin')->default(false); + $table->dateTime('email_confirmed_at')->nullable(); }); } diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php index 3ad23bd..49bed23 100644 --- a/backend/tests/Fakes/FakeUserRepository.php +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -6,6 +6,7 @@ use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; use App\User\User; use App\User\UserRepository; +use RuntimeException; class FakeUserRepository implements UserRepository { @@ -20,12 +21,14 @@ class FakeUserRepository implements UserRepository $user = new User( id: $id, email: $dto->email, + displayName: $dto->displayName, passwordHash: $dto->passwordHash, isAdmin: $dto->isAdmin, + emailConfirmedAt: $dto->emailConfirmedAt, ); $this->existingUsers[$id] = $user; - return $user; + return $this->copy($user); } public function find(int $id): ?User @@ -35,30 +38,59 @@ class FakeUserRepository implements UserRepository return null; } - return new User( - id: $user->getId(), - email: $user->getEmail(), - passwordHash: $user->getPasswordHash(), - isAdmin: $user->isAdmin(), - ); + return $this->copy($user); } public function findByEmail(EmailAddress $email): ?User { foreach ($this->existingUsers as $user) { if ($user->getEmail()->equals($email)) { - return new User( - id: $user->getId(), - email: $user->getEmail(), - passwordHash: $user->getPasswordHash(), - isAdmin: $user->isAdmin(), - ); + return $this->copy($user); } } return null; } + public function findByDisplayName(string $displayName): ?User + { + foreach ($this->existingUsers as $user) { + if ($user->getDisplayName() === $displayName) { + return $this->copy($user); + } + } + + return null; + } + + /** + * @throws RuntimeException + */ + public function update(User $user): User + { + $id = $user->getId(); + if (! isset($this->existingUsers[$id])) { + throw new RuntimeException( + "User with id: $id does not exist" + ); + } + $this->existingUsers[$id] = $user; + + return $this->copy($user); + } + + private function copy(User $user): User + { + return new User( + id: $user->getId(), + email: $user->getEmail(), + displayName: $user->getDisplayName(), + passwordHash: $user->getPasswordHash(), + isAdmin: $user->isAdmin(), + emailConfirmedAt: $user->getEmailConfirmedAt(), + ); + } + private function getNextId(): int { return count($this->existingUsers) + 1; diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php index 7f844c9..232ad61 100644 --- a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -58,13 +58,15 @@ class AuthMiddlewareTest extends TestCase }; } - private function makeUser(int $id = 7): User + private function makeUser(int $id): User { return new User( id: $id, email: new EmailAddress('user@example.com'), + displayName: 'user', passwordHash: 'hashed:irrelevant', isAdmin: false, + emailConfirmedAt: null, ); } @@ -100,7 +102,7 @@ class AuthMiddlewareTest extends TestCase { $this->sessionRepo->create(new CreateSessionDto( token: 'expired-token', - user: $this->makeUser(), + user: $this->makeUser(7), createdAt: $this->now->modify('-8 days'), expiresAt: $this->now->modify('-1 day'), )); diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php index d5460b5..71bcc42 100644 --- a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -46,8 +46,10 @@ class CreateSessionTest extends TestCase return new User( id: 7, email: new EmailAddress('user@example.com'), + displayName: 'user', passwordHash: 'hashed:irrelevant', isAdmin: false, + emailConfirmedAt: null, ); } diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php index c98c582..0d76819 100644 --- a/backend/tests/Unit/Auth/UseCases/LogoutTest.php +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -34,8 +34,10 @@ class LogoutTest extends TestCase $user = new User( id: 7, email: new EmailAddress('user@example.com'), + displayName: 'user', passwordHash: 'hashed:irrelevant', isAdmin: false, + emailConfirmedAt: null, ); $this->sessionRepo->create(new CreateSessionDto( token: 'token-abc',