From a061800306cf9c3926ea500a403a3d95ca996390 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Fri, 17 Apr 2026 18:33:46 +0300 Subject: [PATCH 01/22] Strengthened User/AppUser RT lifecycle logic --- src/Modules/Identity/Domain/Users/User.cs | 2 +- .../Infrastructure/Identity/ShadowEntities/AppUser.cs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Modules/Identity/Domain/Users/User.cs b/src/Modules/Identity/Domain/Users/User.cs index 5ecb160df..ed660e749 100644 --- a/src/Modules/Identity/Domain/Users/User.cs +++ b/src/Modules/Identity/Domain/Users/User.cs @@ -63,6 +63,6 @@ public RefreshToken AddRefreshToken(string token, bool longerSession) public bool RemoveRefreshToken(RefreshToken rt) { - return refreshTokens.Remove(rt); + return refreshTokens.RemoveAll(x => x.Id == rt.Id) == 1; } } diff --git a/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppUser.cs b/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppUser.cs index 5a3e3d39a..90b082882 100644 --- a/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppUser.cs +++ b/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppUser.cs @@ -36,7 +36,16 @@ public string Username internal AppUser FillRefreshTokens(ICollection refreshTokens) { - this.refreshTokens = [.. refreshTokens]; + Dictionary incomingById = refreshTokens.ToDictionary(x => x.Id); + + this.refreshTokens.RemoveAll(rt => !incomingById.ContainsKey(rt.Id)); + this.refreshTokens.RemoveAll(rt => !refreshTokens.Any(nt => nt.Id == rt.Id)); + foreach (var token in refreshTokens) + { + if (!this.refreshTokens.Any(rt => rt.Id == token.Id)) + this.refreshTokens.Add(token); + } + return this; } From 7187b6c6056f2b6985dfb8ff2163e928a7156616 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 18 Apr 2026 16:40:11 +0300 Subject: [PATCH 02/22] Strengthened Register Validator --- .../Post/Register/RegisterRequestValidator.cs | 21 ---------- .../Post/Register/RegisterValidator.cs | 38 +++++++++++++++++++ 2 files changed, 38 insertions(+), 21 deletions(-) delete mode 100644 src/Modules/Identity/API/Identity/Post/Register/RegisterRequestValidator.cs create mode 100644 src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs diff --git a/src/Modules/Identity/API/Identity/Post/Register/RegisterRequestValidator.cs b/src/Modules/Identity/API/Identity/Post/Register/RegisterRequestValidator.cs deleted file mode 100644 index f83271ea4..000000000 --- a/src/Modules/Identity/API/Identity/Post/Register/RegisterRequestValidator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using CustomCADs.Shared.Application; -using CustomCADs.Shared.Domain; -using FluentValidation; - -namespace CustomCADs.Modules.Identity.API.Identity.Post.Register; - -using static ApplicationConstants.FluentMessages; -using static DomainConstants; - -public class RegisterRequestValidator : Validator -{ - public RegisterRequestValidator() - { - RuleFor(x => x.Role) - .Must(x => x is Roles.Customer or Roles.Contributor); - - RuleFor(x => x.ConfirmPassword) - .NotEmpty().WithMessage(RequiredError) - .Equal(x => x.Password).WithMessage("Passwords must be equal!"); - } -} diff --git a/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs b/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs new file mode 100644 index 000000000..bc4690029 --- /dev/null +++ b/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs @@ -0,0 +1,38 @@ +using CustomCADs.Modules.Identity.Application.Contracts; +using CustomCADs.Shared.Application; +using CustomCADs.Shared.Domain; +using FluentValidation; + +namespace CustomCADs.Modules.Identity.API.Identity.Post.Register; + +using static ApplicationConstants.FluentMessages; +using static DomainConstants; + +public class RegisterRequestValidator : Validator +{ + public RegisterRequestValidator() + { + RuleFor(x => x.Role) + .Must(x => x is Roles.Customer or Roles.Contributor) + .WithMessage("""Role must be either "Customer" or "Contributor" """); + + + RuleFor(x => x.Username) + .MustAsync( + async (username, ct) => !await Resolve() + .GetExistsByUsernameAsync(username) + .ConfigureAwait(false) + ).WithMessage("Cannot register a User with a Duplicate Username"); + + RuleFor(x => x.Email) + .MustAsync( + async (email, ct) => !await Resolve() + .GetExistsByEmailAsync(email) + .ConfigureAwait(false) + ).WithMessage("Cannot register a User with a Duplicate Email"); + + RuleFor(x => x.ConfirmPassword) + .NotEmpty().WithMessage(RequiredError) + .Equal(x => x.Password).WithMessage("Passwords must be equal!"); + } +} From d2d854a3f2df4e8ce1b6e81c203237538e886ac9 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Fri, 24 Apr 2026 03:39:56 +0300 Subject: [PATCH 03/22] Critical Identity SSO fixes and improvements --- .../Identity/API/SSODependencyInjection.cs | 9 ++++++++- .../SSO/Register/SingleSignOnUserCommand.cs | 2 ++ .../SSO/Register/SingleSignOnUserHandler.cs | 8 ++++---- .../API/Extensions/ClaimsPrincipalExtensions.cs | 16 +++++++++++++++- src/Tools/CodeGen/ProgramExtensions.cs | 2 +- src/Tools/Identity/ProgramExtensions.cs | 2 +- .../Internal/SSO/SingleSignOnUserUnitTests.cs | 10 ++++++++++ 7 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/Modules/Identity/API/SSODependencyInjection.cs b/src/Modules/Identity/API/SSODependencyInjection.cs index b2cfdb80a..bd926b153 100644 --- a/src/Modules/Identity/API/SSODependencyInjection.cs +++ b/src/Modules/Identity/API/SSODependencyInjection.cs @@ -66,7 +66,12 @@ internal void Configure(SSOClientSettings sso, string domain, string provider, s IServiceProvider sp = ctx.Request.HttpContext.RequestServices; if (ctx.Principal is null) throw new Exception("Claims required"); - ctx.Principal.ExtractUserFromSSO(out string email, out string username); + ctx.Principal.ExtractUserFromSSO( + out string email, + out string username, + out string? firstName, + out string? lastName + ); string? role = null; ctx.Properties?.Items.TryGetValue("role", out role); @@ -80,6 +85,8 @@ internal void Configure(SSOClientSettings sso, string domain, string provider, s tokens: await sender.SendCommandAsync( command: new SingleSignOnUserCommand( Role: role, + FirstName: firstName, + LastName: lastName, Username: username, Email: email, Provider: provider diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs index 37de01244..caafd76b3 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs @@ -4,6 +4,8 @@ namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.SSO.Re public sealed record SingleSignOnUserCommand( string? Role, + string? FirstName, + string? LastName, string Username, string Email, string Provider diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs index 002f17a8e..e39e2e03a 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs @@ -38,16 +38,16 @@ private async Task GetUserAsync(SingleSignOnUserCommand req, CancellationT (true, true) => await service.GetByUsernameAsync(req.Username).ConfigureAwait(false), }; - async Task CreateUser(SingleSignOnUserCommand req, CancellationToken ct) + async Task CreateUser(SingleSignOnUserCommand req, CancellationToken ct, string defaultRole = DomainConstants.Roles.Customer) { - string role = req.Role ?? DomainConstants.Roles.Customer; + string role = string.IsNullOrWhiteSpace(req.Role) ? defaultRole : req.Role; AccountId accountId = await sender.SendCommandAsync( command: new CreateAccountCommand( Role: role, Username: req.Username, Email: req.Email, - FirstName: null, - LastName: null + FirstName: req.FirstName, + LastName: req.LastName ), ct: ct ).ConfigureAwait(false); diff --git a/src/Shared/API/Extensions/ClaimsPrincipalExtensions.cs b/src/Shared/API/Extensions/ClaimsPrincipalExtensions.cs index 15f371933..f6c63ee59 100644 --- a/src/Shared/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/src/Shared/API/Extensions/ClaimsPrincipalExtensions.cs @@ -12,7 +12,7 @@ public static class ClaimsPrincipalExtensions public bool IsAuthenticated => user.Identity?.IsAuthenticated ?? false; public string? Authorization => user.FindFirstValue(ClaimTypes.Role); - public void ExtractUserFromSSO(out string email, out string username) + public void ExtractUserFromSSO(out string email, out string username, out string? firstName, out string? lastName) { email = user.Claims.FirstOrDefault(c => c.Type switch { @@ -29,6 +29,20 @@ public void ExtractUserFromSSO(out string email, out string username) "name" => true, _ => false, })?.Value ?? email.Split('@').First(); + + firstName = user.Claims.FirstOrDefault(c => c.Type switch + { + ClaimTypes.GivenName => true, + "givenname" => true, + _ => false, + })?.Value; + + lastName = user.Claims.FirstOrDefault(c => c.Type switch + { + ClaimTypes.Surname => true, + "surname" => true, + _ => false, + })?.Value; } } } diff --git a/src/Tools/CodeGen/ProgramExtensions.cs b/src/Tools/CodeGen/ProgramExtensions.cs index 82dd774d1..41fbf7344 100644 --- a/src/Tools/CodeGen/ProgramExtensions.cs +++ b/src/Tools/CodeGen/ProgramExtensions.cs @@ -135,7 +135,7 @@ public IServiceCollection AddIdentity(IConfiguration config) options.Password.RequireUppercase = false; options.Password.RequiredLength = PasswordMinLength; options.User.RequireUniqueEmail = true; - options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+" + ' '; // default + space + options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+" + ' ' + '/'; // default + space + '/' options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); }) diff --git a/src/Tools/Identity/ProgramExtensions.cs b/src/Tools/Identity/ProgramExtensions.cs index 84633e9d7..9330cd443 100644 --- a/src/Tools/Identity/ProgramExtensions.cs +++ b/src/Tools/Identity/ProgramExtensions.cs @@ -32,7 +32,7 @@ public IServiceCollection AddIdentity(IConfiguration config) options.Password.RequireUppercase = false; options.Password.RequiredLength = PasswordMinLength; options.User.RequireUniqueEmail = true; - options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+" + ' '; // default + space + options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+" + ' ' + '/'; // default + space + '/' options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); }) diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/SSO/SingleSignOnUserUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/SSO/SingleSignOnUserUnitTests.cs index 0c3ed79d1..7da4099ed 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/SSO/SingleSignOnUserUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/SSO/SingleSignOnUserUnitTests.cs @@ -50,6 +50,8 @@ public async Task Handle_ShouldCallService() // Arrange SingleSignOnUserCommand command = new( Role: User.Role, + FirstName: null, + LastName: null, Username: User.Username, Email: User.Email.Value, Provider: Provider @@ -76,6 +78,8 @@ public async Task Handle_ShouldCallGetByEmail_WhenUsernameDoesNotExist() SingleSignOnUserCommand command = new( Role: User.Role, + FirstName: null, + LastName: null, Username: User.Username, Email: User.Email.Value, Provider: Provider @@ -97,6 +101,8 @@ public async Task Handle_ShouldCallCreateUser_WhenUsernameAndEmailDoNotExist() SingleSignOnUserCommand command = new( Role: User.Role, + FirstName: null, + LastName: null, Username: User.Username, Email: User.Email.Value, Provider: Provider @@ -130,6 +136,8 @@ public async Task Handle_ShouldIssueTokens() // Arrange SingleSignOnUserCommand command = new( Role: User.Role, + FirstName: null, + LastName: null, Username: User.Username, Email: User.Email.Value, Provider: Provider @@ -151,6 +159,8 @@ public async Task Handle_ShouldReturnResult() // Arrange SingleSignOnUserCommand command = new( Role: User.Role, + FirstName: null, + LastName: null, Username: User.Username, Email: User.Email.Value, Provider: Provider From ec85191b2c84f8a6922561fbc7bd1e5505a4898c Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Fri, 24 Apr 2026 03:46:51 +0300 Subject: [PATCH 04/22] Removed `IdentityNames` metadata --- .../Identity/Delete/DeleteAccountEndpoint.cs | 1 - .../Authentication/AuthenticationEndpoint.cs | 1 - .../Authorization/AuthorizationEndpoint.cs | 1 - .../Get/DownloadInfo/DownloadInfoEndpoint.cs | 1 - .../Get/MyAccount/MyAccountEndpoint.cs | 1 - .../Identity/API/Identity/IdentityNames.cs | 22 ------------------- .../ChangeUsername/ChangeUsernameEndpoint.cs | 1 - .../ToggleViewedProductsTrackingEndpoint.cs | 1 - .../ForgotPassword/ForgotPasswordEndpoint.cs | 1 - .../API/Identity/Post/Login/LoginEndpoint.cs | 1 - .../Identity/Post/Logout/LogoutEndpoint.cs | 1 - .../Post/RefreshToken/RefreshTokenEndpoint.cs | 1 - .../Post/Register/RegisterEndpoint.cs | 1 - .../ResetPassword/ResetPasswordEndpoint.cs | 1 - .../RetryConfirmEmailEndpoint.cs | 1 - .../Post/VerifyEmail/ConfirmEmailEndpoint.cs | 1 - 16 files changed, 37 deletions(-) delete mode 100644 src/Modules/Identity/API/Identity/IdentityNames.cs diff --git a/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs b/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs index 077390dc1..1c2e99c0a 100644 --- a/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs @@ -12,7 +12,6 @@ public override void Configure() Delete(""); Group(); Description(x => x - .WithName(IdentityNames.DeleteAccount) .WithSummary("Delete") .WithDescription("Delete your account") ); diff --git a/src/Modules/Identity/API/Identity/Get/Authentication/AuthenticationEndpoint.cs b/src/Modules/Identity/API/Identity/Get/Authentication/AuthenticationEndpoint.cs index 008027556..689fa94cc 100644 --- a/src/Modules/Identity/API/Identity/Get/Authentication/AuthenticationEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/Authentication/AuthenticationEndpoint.cs @@ -9,7 +9,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.Authentication) .WithSummary("AuthN") .WithDescription("See if you're logged in") ); diff --git a/src/Modules/Identity/API/Identity/Get/Authorization/AuthorizationEndpoint.cs b/src/Modules/Identity/API/Identity/Get/Authorization/AuthorizationEndpoint.cs index da4fae1e5..517bed875 100644 --- a/src/Modules/Identity/API/Identity/Get/Authorization/AuthorizationEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/Authorization/AuthorizationEndpoint.cs @@ -9,7 +9,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.Authorization) .WithSummary("AuthZ") .WithDescription("See what Role you're logged in with") ); diff --git a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs index b0f2038ae..083cabb8e 100644 --- a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs @@ -11,7 +11,6 @@ public override void Configure() Get("download-info"); Group(); Description(x => x - .WithName(IdentityNames.DownloadInfo) .WithSummary("Download Info") .WithDescription("Download all your persisted info") ); diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs index b55e3334c..ae0f3cb87 100644 --- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs @@ -10,7 +10,6 @@ public override void Configure() Get("my-account"); Group(); Description(x => x - .WithName(IdentityNames.MyAccount) .WithSummary("My Account") .WithDescription("See your Account's details") ); diff --git a/src/Modules/Identity/API/Identity/IdentityNames.cs b/src/Modules/Identity/API/Identity/IdentityNames.cs deleted file mode 100644 index a420d38bf..000000000 --- a/src/Modules/Identity/API/Identity/IdentityNames.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace CustomCADs.Modules.Identity.API.Identity; - -public static class IdentityNames -{ - public const string Register = "Register"; - public const string ConfirmEmail = "Confirm Email"; - public const string RetryConfirmEmail = "Retry Confirm Email"; - - public const string Login = "Login"; - public const string Refresh = "Refresh"; - public const string ForgotPassword = "Forgot Password"; - public const string ResetPassword = "Reset Password"; - public const string Logout = "Logout"; - public const string ChangeUsername = "Change Username"; - public const string ToggleViewedProductsTracking = "Toggle Viewed Products Tracking"; - public const string DeleteAccount = "Delete Account"; - - public const string Authentication = "Authentication"; - public const string Authorization = "Authorization"; - public const string MyAccount = "My Account"; - public const string DownloadInfo = "Download Info"; -} diff --git a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs b/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs index 038a31396..c04cc4c48 100644 --- a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs @@ -10,7 +10,6 @@ public override void Configure() Patch("username"); Group(); Description(x => x - .WithName(IdentityNames.ChangeUsername) .WithSummary("Change Username") .WithDescription("Change your Username") ); diff --git a/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs b/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs index bb216759d..5ba3d68f2 100644 --- a/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs @@ -10,7 +10,6 @@ public override void Configure() Patch("viewed-products"); Group(); Description(x => x - .WithName(IdentityNames.ToggleViewedProductsTracking) .WithSummary("Viewed Products Tracking") .WithDescription("Toggle whether the Products you View get Tracked") ); diff --git a/src/Modules/Identity/API/Identity/Post/ForgotPassword/ForgotPasswordEndpoint.cs b/src/Modules/Identity/API/Identity/Post/ForgotPassword/ForgotPasswordEndpoint.cs index 878a78694..38f94c914 100644 --- a/src/Modules/Identity/API/Identity/Post/ForgotPassword/ForgotPasswordEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/ForgotPassword/ForgotPasswordEndpoint.cs @@ -12,7 +12,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.ForgotPassword) .WithSummary("Reset Password Email") .WithDescription("Receive an Email with a link to reset your Password") .WithMetadata(new SkipIdempotencyAttribute()) diff --git a/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs b/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs index a14147887..0f85a0b4d 100644 --- a/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs @@ -14,7 +14,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.Login) .WithSummary("Login") .WithDescription("Log in to your account") .WithMetadata(new SkipIdempotencyAttribute()) diff --git a/src/Modules/Identity/API/Identity/Post/Logout/LogoutEndpoint.cs b/src/Modules/Identity/API/Identity/Post/Logout/LogoutEndpoint.cs index 8fa45935c..b2fe18a25 100644 --- a/src/Modules/Identity/API/Identity/Post/Logout/LogoutEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/Logout/LogoutEndpoint.cs @@ -13,7 +13,6 @@ public override void Configure() Post("logout"); Group(); Description(x => x - .WithName(IdentityNames.Logout) .WithSummary("Log out") .WithDescription("Log out of your account") .WithMetadata(new SkipIdempotencyAttribute()) diff --git a/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs b/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs index 046eb6015..27e6c137d 100644 --- a/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs @@ -14,7 +14,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.Refresh) .WithSummary("Refresh") .WithDescription("Refresh your login") .WithMetadata(new SkipIdempotencyAttribute()) diff --git a/src/Modules/Identity/API/Identity/Post/Register/RegisterEndpoint.cs b/src/Modules/Identity/API/Identity/Post/Register/RegisterEndpoint.cs index f0e5019c9..ae4d3a373 100644 --- a/src/Modules/Identity/API/Identity/Post/Register/RegisterEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/Register/RegisterEndpoint.cs @@ -13,7 +13,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.Register) .WithSummary("Register") .WithDescription("Register an Account") .WithMetadata(new SkipIdempotencyAttribute()) diff --git a/src/Modules/Identity/API/Identity/Post/ResetPassword/ResetPasswordEndpoint.cs b/src/Modules/Identity/API/Identity/Post/ResetPassword/ResetPasswordEndpoint.cs index 105029972..f3eb214f6 100644 --- a/src/Modules/Identity/API/Identity/Post/ResetPassword/ResetPasswordEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/ResetPassword/ResetPasswordEndpoint.cs @@ -12,7 +12,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.ResetPassword) .WithSummary("Reset Password") .WithDescription("Reset your Password with the token from the email") .WithMetadata(new SkipIdempotencyAttribute()) diff --git a/src/Modules/Identity/API/Identity/Post/RetryVerifyEmail/RetryConfirmEmailEndpoint.cs b/src/Modules/Identity/API/Identity/Post/RetryVerifyEmail/RetryConfirmEmailEndpoint.cs index 490f2bc3a..9f15451c0 100644 --- a/src/Modules/Identity/API/Identity/Post/RetryVerifyEmail/RetryConfirmEmailEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/RetryVerifyEmail/RetryConfirmEmailEndpoint.cs @@ -12,7 +12,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.RetryConfirmEmail) .WithSummary("Retry Send Email") .WithDescription("Receive another verification email") .WithMetadata(new SkipIdempotencyAttribute()) diff --git a/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs b/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs index 77fcb07a2..3190dd61a 100644 --- a/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs @@ -14,7 +14,6 @@ public override void Configure() Group(); AllowAnonymous(); Description(x => x - .WithName(IdentityNames.ConfirmEmail) .WithSummary("Confirm Email") .WithDescription("Confirm the verification email") .WithMetadata(new SkipIdempotencyAttribute()) From 08f4ca0d7bc3a39ed5302520afe3aa3221c7283b Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 25 Apr 2026 00:45:32 +0300 Subject: [PATCH 05/22] Significantly refactored Users/Roles constants --- .../API/Accounts/Endpoints/AccountsGroup.cs | 4 +- .../API/Roles/Endpoints/RolesGroup.cs | 4 +- src/Modules/Accounts/Domain/Constants.cs | 17 +++++++ .../Configurations/Accounts/Utilities.cs | 9 ++-- .../Configurations/Roles/Utilities.cs | 26 ++++++++--- .../ActiveCarts/Endpoints/ActiveCartsGroup.cs | 4 +- .../Endpoints/PurchasedCartsGroup.cs | 4 +- .../Categories/Endpoints/CategoriesGroup.cs | 4 +- .../Products/Endpoints/Admin/AdminGroup.cs | 4 +- .../Endpoints/Creator/CreatorGroup.cs | 4 +- .../Endpoints/Designer/DesignerGroup.cs | 4 +- .../Patch/AddTag/AddProductTagEndpoint.cs | 4 +- .../RemoveTag/RemoveProductTagEndpoint.cs | 4 +- .../Catalog/API/Tags/Endpoints/TagGroup.cs | 4 +- .../Creator/Create/CreateProductHandler.cs | 4 +- .../Policies/ProductCadUploadPolicy.cs | 4 +- .../Customs/Endpoints/Admins/AdminGroup.cs | 4 +- .../Endpoints/Customers/CustomerGroup.cs | 4 +- .../Endpoints/Designer/DesignerGroup.cs | 4 +- .../Customers/Create/CreateCustomHandler.cs | 2 +- .../Policies/CustomCadDownloadPolicy.cs | 4 +- .../Customs/Policies/CustomCadUploadPolicy.cs | 4 +- .../Waybill/GetShipmentGetWaybillEndpoint.cs | 4 +- .../API/Shipments/Endpoints/ShipmentsGroup.cs | 4 +- .../GetWaybill/GetShipmentWaybillHandler.cs | 3 +- .../Files/API/Cads/Endpoints/CadsGroup.cs | 4 +- .../Files/API/Images/Endpoints/ImagesGroup.cs | 4 +- .../Configurations/Images/Utilities.cs | 2 +- .../Post/Register/RegisterValidator.cs | 4 +- .../SSO/Register/SingleSignOnUserHandler.cs | 2 +- src/Modules/Identity/Domain/Constants.cs | 15 +++++++ .../Domain/Users/Entities/RefreshToken.cs | 3 +- .../Identity/Infrastructure/Constants.cs | 20 +++++++++ .../Identity/Infrastructure/GlobalUsings.cs | 1 + .../Configurations/AppRoles/Utilities.cs | 35 ++++++++++++--- .../AppUserRoles/Configurations.cs | 2 - .../Configurations/AppUserRoles/Utilities.cs | 14 +++--- .../Configurations/AppUsers/Utilities.cs | 39 ++++++++-------- .../Tokens/IdentityTokenService.cs | 3 +- .../Endpoints/NotificationsGroup.cs | 4 +- .../API/Materials/Endpoints/MaterialsGroup.cs | 4 +- .../Policies/MaterialTextureReplacePolicy.cs | 2 +- .../Policies/MaterialTextureUploadPolicy.cs | 2 +- src/Presentation/Program.cs | 4 +- src/Shared/Domain/DomainConstants.cs | 45 +++++-------------- .../Create/CreateAccountHandlerUnitTests.cs | 11 +++-- .../Create/CreateAccountHandlerUnitTests.cs | 14 +++--- .../GetAll/GetAllAccountsHandlerUnitTests.cs | 11 ++--- .../GetUsernamesByIdsHandlerUnitTests.cs | 9 ++-- .../GetAll/GetAllRolesHandlerUnitTests.cs | 10 ++--- .../Application/Roles/RolesBaseUnitTests.cs | 4 +- tests/UnitTests/Accounts/Data/AccountsData.cs | 4 +- tests/UnitTests/Accounts/Data/RolesData.cs | 6 +-- .../Email/Data/AccountEmailValidData.cs | 1 - .../Create/CreateProductHandlerUnitTests.cs | 6 +-- .../GetShipmentWaybillHandlerUnitTests.cs | 10 ++--- .../UnitTests/Delivery/Data/ShipmentsData.cs | 2 +- .../Refresh/RefreshUserHandlerUnitTests.cs | 3 +- tests/UnitTests/Identity/Data/UsersData.cs | 2 +- .../UserAddRefreshTokenUnitTests.cs | 4 +- 60 files changed, 244 insertions(+), 199 deletions(-) create mode 100644 src/Modules/Accounts/Domain/Constants.cs create mode 100644 src/Modules/Identity/Domain/Constants.cs create mode 100644 src/Modules/Identity/Infrastructure/Constants.cs create mode 100644 src/Modules/Identity/Infrastructure/GlobalUsings.cs diff --git a/src/Modules/Accounts/API/Accounts/Endpoints/AccountsGroup.cs b/src/Modules/Accounts/API/Accounts/Endpoints/AccountsGroup.cs index 055628a99..3428c55a2 100644 --- a/src/Modules/Accounts/API/Accounts/Endpoints/AccountsGroup.cs +++ b/src/Modules/Accounts/API/Accounts/Endpoints/AccountsGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Accounts.API.Accounts.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class AccountsGroup : Group { @@ -9,7 +9,7 @@ public AccountsGroup() { Configure(Paths.Accounts, x => { - x.Roles(Admin); + x.Roles(AdminRole); x.Description(opt => opt.WithTags(Tags[Paths.Accounts])); }); } diff --git a/src/Modules/Accounts/API/Roles/Endpoints/RolesGroup.cs b/src/Modules/Accounts/API/Roles/Endpoints/RolesGroup.cs index 939b315a8..7803b1a5a 100644 --- a/src/Modules/Accounts/API/Roles/Endpoints/RolesGroup.cs +++ b/src/Modules/Accounts/API/Roles/Endpoints/RolesGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Accounts.API.Roles.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class RolesGroup : Group { @@ -9,7 +9,7 @@ public RolesGroup() { Configure(Paths.Roles, x => { - x.Roles(Admin); + x.Roles(AdminRole); x.Description(opt => opt.WithTags(Tags[Paths.Roles])); }); } diff --git a/src/Modules/Accounts/Domain/Constants.cs b/src/Modules/Accounts/Domain/Constants.cs new file mode 100644 index 000000000..63bec8b0f --- /dev/null +++ b/src/Modules/Accounts/Domain/Constants.cs @@ -0,0 +1,17 @@ +namespace CustomCADs.Modules.Accounts.Domain; + +public static class Constants +{ + public static class Roles + { + public static readonly RoleId CustomerId = RoleId.New(1); + public static readonly RoleId ContributorId = RoleId.New(2); + public static readonly RoleId DesignerId = RoleId.New(3); + public static readonly RoleId AdminId = RoleId.New(4); + + public const string CustomerDescription = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments."; + public const string ContributorDescription = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself."; + public const string DesignerDescription = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do."; + public const string AdminDescription = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles."; + } +} diff --git a/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs b/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs index b04bd1203..ddb347a73 100644 --- a/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs +++ b/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs @@ -5,7 +5,6 @@ namespace CustomCADs.Modules.Accounts.Persistence.Configurations.Accounts; using static AccountConstants; -using static DomainConstants.Roles; using static DomainConstants.Users; internal static class Utilities @@ -76,10 +75,10 @@ internal EntityTypeBuilder SetValidations() internal EntityTypeBuilder SetSeeding() { builder.HasData([ - Account.CreateWithId(AccountId.New(CustomerAccountId), Customer, CustomerUsername, CustomerEmail, new DateTimeOffset(2025, 05, 10, 19, 23, 12, 123, TimeSpan.FromHours(3))), - Account.CreateWithId(AccountId.New(ContributorAccountId), Contributor, ContributorUsername, ContributorEmail, new DateTimeOffset(2025, 05, 13, 17, 42, 57, 456, TimeSpan.FromHours(3))), - Account.CreateWithId(AccountId.New(DesignerAccountId), Designer, DesignerUsername, DesignerEmail, new DateTimeOffset(2025, 01, 09, 13, 15, 28, 789, TimeSpan.FromHours(3))), - Account.CreateWithId(AccountId.New(AdminAccountId), Admin, AdminUsername, AdminEmail, new DateTimeOffset(2024, 03, 17, 02, 45, 13, 000, TimeSpan.FromHours(3))), + Account.CreateWithId(CustomerAccountId, CustomerRole, CustomerUsername, CustomerEmail, new DateTimeOffset(2025, 05, 10, 19, 23, 12, 123, TimeSpan.FromHours(3))), + Account.CreateWithId(ContributorAccountId, ContributorRole, ContributorUsername, ContributorEmail, new DateTimeOffset(2025, 05, 13, 17, 42, 57, 456, TimeSpan.FromHours(3))), + Account.CreateWithId(DesignerAccountId, DesignerRole, DesignerUsername, DesignerEmail, new DateTimeOffset(2025, 01, 09, 13, 15, 28, 789, TimeSpan.FromHours(3))), + Account.CreateWithId(AdminAccountId, AdminRole, AdminUsername, AdminEmail, new DateTimeOffset(2024, 03, 17, 02, 45, 13, 000, TimeSpan.FromHours(3))), ]); return builder; diff --git a/src/Modules/Accounts/Persistence/Configurations/Roles/Utilities.cs b/src/Modules/Accounts/Persistence/Configurations/Roles/Utilities.cs index b25a57608..a7eec3902 100644 --- a/src/Modules/Accounts/Persistence/Configurations/Roles/Utilities.cs +++ b/src/Modules/Accounts/Persistence/Configurations/Roles/Utilities.cs @@ -1,10 +1,8 @@ using CustomCADs.Modules.Accounts.Domain.Roles; -using CustomCADs.Shared.Domain; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CustomCADs.Modules.Accounts.Persistence.Configurations.Roles; -using static DomainConstants.Roles; using static RoleConstants; internal static class Utilities @@ -48,10 +46,26 @@ internal EntityTypeBuilder SetValidations() internal EntityTypeBuilder SetSeeding() { builder.HasData([ - Role.CreateWithId(RoleId.New(1), Customer, CustomerDescription), - Role.CreateWithId(RoleId.New(2), Contributor, ContributorDescription), - Role.CreateWithId(RoleId.New(3), Designer, DesignerDescription), - Role.CreateWithId(RoleId.New(4), Admin, AdminDescription), + Role.CreateWithId( + id: Domain.Constants.Roles.CustomerId, + name: Shared.Domain.DomainConstants.Users.CustomerRole, + description: Domain.Constants.Roles.CustomerDescription + ), + Role.CreateWithId( + id: Domain.Constants.Roles.ContributorId, + name: Shared.Domain.DomainConstants.Users.ContributorRole, + description: Domain.Constants.Roles.ContributorDescription + ), + Role.CreateWithId( + id: Domain.Constants.Roles.DesignerId, + name: Shared.Domain.DomainConstants.Users.DesignerRole, + description: Domain.Constants.Roles.DesignerDescription + ), + Role.CreateWithId( + id: Domain.Constants.Roles.AdminId, + name: Shared.Domain.DomainConstants.Users.AdminRole, + description: Domain.Constants.Roles.AdminDescription + ), ]); return builder; diff --git a/src/Modules/Carts/API/ActiveCarts/Endpoints/ActiveCartsGroup.cs b/src/Modules/Carts/API/ActiveCarts/Endpoints/ActiveCartsGroup.cs index 3ca7481f8..a6d060a3f 100644 --- a/src/Modules/Carts/API/ActiveCarts/Endpoints/ActiveCartsGroup.cs +++ b/src/Modules/Carts/API/ActiveCarts/Endpoints/ActiveCartsGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Carts.API.ActiveCarts.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class ActiveCartsGroup : Group { @@ -9,7 +9,7 @@ public ActiveCartsGroup() { Configure(Paths.ActiveCarts, x => { - x.Roles(Customer); + x.Roles(CustomerRole); x.Description(x => x.WithTags(Tags[Paths.ActiveCarts])); }); } diff --git a/src/Modules/Carts/API/PurchasedCarts/Endpoints/PurchasedCartsGroup.cs b/src/Modules/Carts/API/PurchasedCarts/Endpoints/PurchasedCartsGroup.cs index fd1cc71fc..4471a191a 100644 --- a/src/Modules/Carts/API/PurchasedCarts/Endpoints/PurchasedCartsGroup.cs +++ b/src/Modules/Carts/API/PurchasedCarts/Endpoints/PurchasedCartsGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Carts.API.PurchasedCarts.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class PurchasedCartsGroup : Group { @@ -9,7 +9,7 @@ public PurchasedCartsGroup() { Configure(Paths.PurchasedCarts, x => { - x.Roles(Customer); + x.Roles(CustomerRole); x.Description(x => x.WithTags(Tags[Paths.PurchasedCarts])); }); } diff --git a/src/Modules/Catalog/API/Categories/Endpoints/CategoriesGroup.cs b/src/Modules/Catalog/API/Categories/Endpoints/CategoriesGroup.cs index 8b8284b94..8fd1f6d6d 100644 --- a/src/Modules/Catalog/API/Categories/Endpoints/CategoriesGroup.cs +++ b/src/Modules/Catalog/API/Categories/Endpoints/CategoriesGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Catalog.API.Categories.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class CategoriesGroup : Group { @@ -9,7 +9,7 @@ public CategoriesGroup() { Configure(Paths.Categories, x => { - x.Roles(Admin); + x.Roles(AdminRole); x.Description(x => x.WithTags(Tags[Paths.Categories])); }); } diff --git a/src/Modules/Catalog/API/Products/Endpoints/Admin/AdminGroup.cs b/src/Modules/Catalog/API/Products/Endpoints/Admin/AdminGroup.cs index 93dac952f..db157542a 100644 --- a/src/Modules/Catalog/API/Products/Endpoints/Admin/AdminGroup.cs +++ b/src/Modules/Catalog/API/Products/Endpoints/Admin/AdminGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Admin; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class AdminGroup : SubGroup { @@ -9,7 +9,7 @@ public AdminGroup() { Configure(Paths.Admin, x => { - x.Roles(Admin); + x.Roles(AdminRole); x.Description(x => x.WithTags(Tags[$"{Paths.Products}/{Paths.Admin}"])); }); } diff --git a/src/Modules/Catalog/API/Products/Endpoints/Creator/CreatorGroup.cs b/src/Modules/Catalog/API/Products/Endpoints/Creator/CreatorGroup.cs index 838c938c9..a865f1cb2 100644 --- a/src/Modules/Catalog/API/Products/Endpoints/Creator/CreatorGroup.cs +++ b/src/Modules/Catalog/API/Products/Endpoints/Creator/CreatorGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Creator; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class CreatorGroup : SubGroup { @@ -9,7 +9,7 @@ public CreatorGroup() { Configure(Paths.Creator, x => { - x.Roles(Contributor, Designer); + x.Roles(ContributorRole, DesignerRole); x.Description(x => x.WithTags(Tags[$"{Paths.Products}/{Paths.Creator}"])); }); } diff --git a/src/Modules/Catalog/API/Products/Endpoints/Designer/DesignerGroup.cs b/src/Modules/Catalog/API/Products/Endpoints/Designer/DesignerGroup.cs index 73ecaff9f..0f94f18f5 100644 --- a/src/Modules/Catalog/API/Products/Endpoints/Designer/DesignerGroup.cs +++ b/src/Modules/Catalog/API/Products/Endpoints/Designer/DesignerGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Designer; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class DesignerGroup : SubGroup { @@ -9,7 +9,7 @@ public DesignerGroup() { Configure(Paths.Designer, x => { - x.Roles(Designer); + x.Roles(DesignerRole); x.Description(x => x.WithTags(Tags[$"{Paths.Products}/{Paths.Designer}"])); }); } diff --git a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/AddTag/AddProductTagEndpoint.cs b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/AddTag/AddProductTagEndpoint.cs index 48aeb3268..078d86b6f 100644 --- a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/AddTag/AddProductTagEndpoint.cs +++ b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/AddTag/AddProductTagEndpoint.cs @@ -2,7 +2,7 @@ namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Gallery.Patch.AddTag; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class AddProductTagEndpoint(IRequestSender sender) : Endpoint @@ -11,7 +11,7 @@ public override void Configure() { Patch("tags/add"); Group(); - Roles(Admin); + Roles(AdminRole); Description(x => x .WithSummary("Add Tag") .WithDescription("Adds a Tag to a Product") diff --git a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/RemoveTag/RemoveProductTagEndpoint.cs b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/RemoveTag/RemoveProductTagEndpoint.cs index 29f13f895..564ba36c5 100644 --- a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/RemoveTag/RemoveProductTagEndpoint.cs +++ b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Patch/RemoveTag/RemoveProductTagEndpoint.cs @@ -2,7 +2,7 @@ namespace CustomCADs.Modules.Catalog.API.Products.Endpoints.Gallery.Patch.RemoveTag; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class RemoveProductTagEndpoint(IRequestSender sender) : Endpoint @@ -11,7 +11,7 @@ public override void Configure() { Patch("tags/remove"); Group(); - Roles(Admin); + Roles(AdminRole); Description(x => x .WithSummary("Remove Tag") .WithDescription("Removes a Tag from a Product") diff --git a/src/Modules/Catalog/API/Tags/Endpoints/TagGroup.cs b/src/Modules/Catalog/API/Tags/Endpoints/TagGroup.cs index 18e289920..07c0c50c1 100644 --- a/src/Modules/Catalog/API/Tags/Endpoints/TagGroup.cs +++ b/src/Modules/Catalog/API/Tags/Endpoints/TagGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Catalog.API.Tags.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class TagGroup : Group { @@ -9,7 +9,7 @@ public TagGroup() { Configure(Paths.Tags, x => { - x.Roles(Admin); + x.Roles(AdminRole); x.Description(x => x.WithTags(Tags[Paths.Tags])); }); } diff --git a/src/Modules/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandler.cs b/src/Modules/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandler.cs index 9e89fb32e..0ec5e3060 100644 --- a/src/Modules/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandler.cs +++ b/src/Modules/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandler.cs @@ -59,7 +59,7 @@ public async Task Handle(CreateProductCommand req, CancellationToken ct: ct ).ConfigureAwait(false); - if (role is Roles.Designer) + if (role is Users.DesignerRole) { product.Validate(req.CallerId); } @@ -71,7 +71,7 @@ await raiser.RaiseApplicationEventAsync( TagIds: TagId.Filter(new() { [Tags.NewId] = true, - [Tags.ProfessionalId] = role is Roles.Designer, + [Tags.ProfessionalId] = role is Users.DesignerRole, [Tags.PrintableId] = await sender.SendQueryAsync( query: new IsCadPrintableByIdQuery(req.CadId), ct: ct diff --git a/src/Modules/Catalog/Application/Products/Policies/ProductCadUploadPolicy.cs b/src/Modules/Catalog/Application/Products/Policies/ProductCadUploadPolicy.cs index 847cca584..b18764781 100644 --- a/src/Modules/Catalog/Application/Products/Policies/ProductCadUploadPolicy.cs +++ b/src/Modules/Catalog/Application/Products/Policies/ProductCadUploadPolicy.cs @@ -5,6 +5,8 @@ namespace CustomCADs.Modules.Catalog.Application.Products.Policies; +using static DomainConstants; + public class ProductCadUploadPolicy(IRequestSender sender) : IFileUploadPolicy { public FileContextType Type => FileContextType.Product; @@ -15,7 +17,7 @@ public async Task EnsureUploadGrantedAsync(IFileUploadPolicy.FileContext query: new GetUserRoleByIdQuery(context.CallerId) ).ConfigureAwait(false); - if (role is not (DomainConstants.Roles.Contributor or DomainConstants.Roles.Designer)) + if (role is not (Users.ContributorRole or Users.DesignerRole)) { throw CustomAuthorizationException.Custom("Must be a Contributor/Designer to upload Product CADs"); } diff --git a/src/Modules/Customs/API/Customs/Endpoints/Admins/AdminGroup.cs b/src/Modules/Customs/API/Customs/Endpoints/Admins/AdminGroup.cs index c4dd05b8c..774ae62d8 100644 --- a/src/Modules/Customs/API/Customs/Endpoints/Admins/AdminGroup.cs +++ b/src/Modules/Customs/API/Customs/Endpoints/Admins/AdminGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Customs.API.Customs.Endpoints.Admins; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class AdminGroup : SubGroup { @@ -9,7 +9,7 @@ public AdminGroup() { Configure(Paths.Admin, x => { - x.Roles(Admin); + x.Roles(AdminRole); x.Description(x => x.WithTags(Tags[$"{Paths.Customs}/{Paths.Admin}"])); }); } diff --git a/src/Modules/Customs/API/Customs/Endpoints/Customers/CustomerGroup.cs b/src/Modules/Customs/API/Customs/Endpoints/Customers/CustomerGroup.cs index e2695418d..14d71af2b 100644 --- a/src/Modules/Customs/API/Customs/Endpoints/Customers/CustomerGroup.cs +++ b/src/Modules/Customs/API/Customs/Endpoints/Customers/CustomerGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Customs.API.Customs.Endpoints.Customers; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class CustomerGroup : SubGroup { @@ -9,7 +9,7 @@ public CustomerGroup() { Configure(Paths.Customer, x => { - x.Roles(Customer); + x.Roles(CustomerRole); x.Description(x => x.WithTags(Tags[$"{Paths.Customs}/{Paths.Customer}"])); }); } diff --git a/src/Modules/Customs/API/Customs/Endpoints/Designer/DesignerGroup.cs b/src/Modules/Customs/API/Customs/Endpoints/Designer/DesignerGroup.cs index 074721819..2227158b5 100644 --- a/src/Modules/Customs/API/Customs/Endpoints/Designer/DesignerGroup.cs +++ b/src/Modules/Customs/API/Customs/Endpoints/Designer/DesignerGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Customs.API.Customs.Endpoints.Designer; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class DesignerGroup : SubGroup { @@ -9,7 +9,7 @@ public DesignerGroup() { Configure(Paths.Designer, x => { - x.Roles(Designer); + x.Roles(DesignerRole); x.Description(x => x.WithTags(Tags[$"{Paths.Customs}/{Paths.Designer}"])); }); } diff --git a/src/Modules/Customs/Application/Customs/Commands/Internal/Customers/Create/CreateCustomHandler.cs b/src/Modules/Customs/Application/Customs/Commands/Internal/Customers/Create/CreateCustomHandler.cs index c8c41199c..e948fad58 100644 --- a/src/Modules/Customs/Application/Customs/Commands/Internal/Customers/Create/CreateCustomHandler.cs +++ b/src/Modules/Customs/Application/Customs/Commands/Internal/Customers/Create/CreateCustomHandler.cs @@ -45,7 +45,7 @@ await raiser.RaiseApplicationEventAsync( Link: Notifications.Links.CustomCreated, AuthorId: custom.BuyerId, ReceiverIds: [.. await sender.SendQueryAsync( - query: new GetAccountIdsByRoleQuery(DomainConstants.Roles.Designer), + query: new GetAccountIdsByRoleQuery(DomainConstants.Users.DesignerRole), ct: ct ).ConfigureAwait(false)] ) diff --git a/src/Modules/Customs/Application/Customs/Policies/CustomCadDownloadPolicy.cs b/src/Modules/Customs/Application/Customs/Policies/CustomCadDownloadPolicy.cs index 5ecaf5e8f..4b472ab16 100644 --- a/src/Modules/Customs/Application/Customs/Policies/CustomCadDownloadPolicy.cs +++ b/src/Modules/Customs/Application/Customs/Policies/CustomCadDownloadPolicy.cs @@ -21,11 +21,11 @@ public async Task EnsureDownloadGrantedAsync(IFileDownloadPolicy.FileCont switch (role) { - case DomainConstants.Roles.Customer: + case DomainConstants.Users.CustomerRole: await EnsureCustomerDownloadGrantedAsync(context).ConfigureAwait(false); break; - case DomainConstants.Roles.Designer: + case DomainConstants.Users.DesignerRole: await EnsureDesignerDownloadGrantedAsync(context).ConfigureAwait(false); break; diff --git a/src/Modules/Customs/Application/Customs/Policies/CustomCadUploadPolicy.cs b/src/Modules/Customs/Application/Customs/Policies/CustomCadUploadPolicy.cs index fc1003a21..f78ae3340 100644 --- a/src/Modules/Customs/Application/Customs/Policies/CustomCadUploadPolicy.cs +++ b/src/Modules/Customs/Application/Customs/Policies/CustomCadUploadPolicy.cs @@ -5,6 +5,8 @@ namespace CustomCADs.Modules.Customs.Application.Customs.Policies; +using static DomainConstants; + public class CustomCadUploadPolicy(IRequestSender sender) : IFileUploadPolicy { public FileContextType Type => FileContextType.Custom; @@ -15,7 +17,7 @@ public async Task EnsureUploadGrantedAsync(IFileUploadPolicy.FileContext query: new GetUserRoleByIdQuery(context.CallerId) ).ConfigureAwait(false); - if (role is not DomainConstants.Roles.Designer) + if (role is not Users.DesignerRole) { throw CustomAuthorizationException.Custom("Must be a Designer to upload a Custom's CAD."); } diff --git a/src/Modules/Delivery/API/Shipments/Endpoints/Get/Waybill/GetShipmentGetWaybillEndpoint.cs b/src/Modules/Delivery/API/Shipments/Endpoints/Get/Waybill/GetShipmentGetWaybillEndpoint.cs index cacb2b89a..c478d33df 100644 --- a/src/Modules/Delivery/API/Shipments/Endpoints/Get/Waybill/GetShipmentGetWaybillEndpoint.cs +++ b/src/Modules/Delivery/API/Shipments/Endpoints/Get/Waybill/GetShipmentGetWaybillEndpoint.cs @@ -2,7 +2,7 @@ namespace CustomCADs.Modules.Delivery.API.Shipments.Endpoints.Get.Waybill; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class GetShipmentWaybillEndpoint(IRequestSender sender) : Endpoint @@ -11,7 +11,7 @@ public override void Configure() { Get("{id}/waybill"); Group(); - Roles(Designer); + Roles(DesignerRole); Description(x => x .WithSummary("Waybill") .WithDescription("Download this Shipment's waybill") diff --git a/src/Modules/Delivery/API/Shipments/Endpoints/ShipmentsGroup.cs b/src/Modules/Delivery/API/Shipments/Endpoints/ShipmentsGroup.cs index ce8965c1e..4feced555 100644 --- a/src/Modules/Delivery/API/Shipments/Endpoints/ShipmentsGroup.cs +++ b/src/Modules/Delivery/API/Shipments/Endpoints/ShipmentsGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Delivery.API.Shipments.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class ShipmentsGroup : Group { @@ -9,7 +9,7 @@ public ShipmentsGroup() { Configure(Paths.Shipments, x => { - x.Roles(Customer); + x.Roles(CustomerRole); x.Description(x => x.WithTags(Tags[Paths.Shipments])); }); } diff --git a/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs b/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs index 682b11be0..647c3b4e7 100644 --- a/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs +++ b/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs @@ -16,8 +16,7 @@ public async Task Handle(GetShipmentWaybillQuery req, CancellationToken Shipment shipment = await reads.SingleByIdAsync(req.Id, track: false, ct: ct).ConfigureAwait(false) ?? throw CustomNotFoundException.ById(req.Id); - Guid headDesignerId = Guid.Parse(DesignerAccountId); - if (req.CallerId.Value != headDesignerId) + if (req.CallerId != DesignerAccountId) { throw CustomAuthorizationException.ById(req.Id); } diff --git a/src/Modules/Files/API/Cads/Endpoints/CadsGroup.cs b/src/Modules/Files/API/Cads/Endpoints/CadsGroup.cs index 10db667c9..9a0a6c703 100644 --- a/src/Modules/Files/API/Cads/Endpoints/CadsGroup.cs +++ b/src/Modules/Files/API/Cads/Endpoints/CadsGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Files.API.Cads.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class CadsGroup : Group { @@ -9,7 +9,7 @@ public CadsGroup() { Configure(Paths.Cads, x => { - x.Roles(Customer, Contributor, Designer, Admin); + x.Roles(CustomerRole, ContributorRole, DesignerRole, AdminRole); x.Description(x => x.WithTags(Tags[Paths.Cads])); }); } diff --git a/src/Modules/Files/API/Images/Endpoints/ImagesGroup.cs b/src/Modules/Files/API/Images/Endpoints/ImagesGroup.cs index 396319778..2994771a5 100644 --- a/src/Modules/Files/API/Images/Endpoints/ImagesGroup.cs +++ b/src/Modules/Files/API/Images/Endpoints/ImagesGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Files.API.Images.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class ImagesGroup : Group { @@ -9,7 +9,7 @@ public ImagesGroup() { Configure(Paths.Images, x => { - x.Roles(Customer, Contributor, Designer, Admin); + x.Roles(CustomerRole, ContributorRole, DesignerRole, AdminRole); x.Description(x => x.WithTags(Tags[Paths.Images])); }); } diff --git a/src/Modules/Files/Persistence/Configurations/Images/Utilities.cs b/src/Modules/Files/Persistence/Configurations/Images/Utilities.cs index e89fc684f..4e7934cac 100644 --- a/src/Modules/Files/Persistence/Configurations/Images/Utilities.cs +++ b/src/Modules/Files/Persistence/Configurations/Images/Utilities.cs @@ -58,7 +58,7 @@ internal EntityTypeBuilder SetValidaitons() internal EntityTypeBuilder SetSeeding() { - AccountId adminId = AccountId.New(Guid.Parse(DomainConstants.Users.AdminAccountId)); + AccountId adminId = DomainConstants.Users.AdminAccountId; builder.HasData([ Image.CreateWithId(PLA, "textures/pla.webp", "image/webp", adminId), Image.CreateWithId(ABS, "textures/abs.webp", "image/webp", adminId), diff --git a/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs b/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs index bc4690029..469e8ed4a 100644 --- a/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs +++ b/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs @@ -6,14 +6,14 @@ namespace CustomCADs.Modules.Identity.API.Identity.Post.Register; using static ApplicationConstants.FluentMessages; -using static DomainConstants; +using static DomainConstants.Users; public class RegisterRequestValidator : Validator { public RegisterRequestValidator() { RuleFor(x => x.Role) - .Must(x => x is Roles.Customer or Roles.Contributor) + .Must(x => x is CustomerRole or ContributorRole) .WithMessage("""Role must be either "Customer" or "Contributor" """); diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs index e39e2e03a..da98ba06a 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs @@ -38,7 +38,7 @@ private async Task GetUserAsync(SingleSignOnUserCommand req, CancellationT (true, true) => await service.GetByUsernameAsync(req.Username).ConfigureAwait(false), }; - async Task CreateUser(SingleSignOnUserCommand req, CancellationToken ct, string defaultRole = DomainConstants.Roles.Customer) + async Task CreateUser(SingleSignOnUserCommand req, CancellationToken ct, string defaultRole = DomainConstants.Users.CustomerRole) { string role = string.IsNullOrWhiteSpace(req.Role) ? defaultRole : req.Role; AccountId accountId = await sender.SendCommandAsync( diff --git a/src/Modules/Identity/Domain/Constants.cs b/src/Modules/Identity/Domain/Constants.cs new file mode 100644 index 000000000..fe3a8d0e5 --- /dev/null +++ b/src/Modules/Identity/Domain/Constants.cs @@ -0,0 +1,15 @@ +using CustomCADs.Shared.Domain.TypedIds.Catalog; +using CustomCADs.Shared.Domain.TypedIds.Files; +using System.Text.RegularExpressions; + +namespace CustomCADs.Modules.Identity.Domain; + +public static partial class Constants +{ + public static class Tokens + { + public const int JwtDurationInMinutes = 15; + public const int RtDurationInDays = 7; + public const int LongerRtDurationInDays = 15; + } +} diff --git a/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs b/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs index 06b56b5b2..20f09b68d 100644 --- a/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs +++ b/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs @@ -1,9 +1,8 @@ -using CustomCADs.Shared.Domain; using CustomCADs.Shared.Domain.Bases.Entities; namespace CustomCADs.Modules.Identity.Domain.Users.Entities; -using static DomainConstants.Tokens; +using static Constants.Tokens; public class RefreshToken : BaseEntity { diff --git a/src/Modules/Identity/Infrastructure/Constants.cs b/src/Modules/Identity/Infrastructure/Constants.cs new file mode 100644 index 000000000..790a99303 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Constants.cs @@ -0,0 +1,20 @@ +namespace CustomCADs.Modules.Identity.Infrastructure; + +public static class Constants +{ + public static class Roles + { + public static readonly Guid CustomerId = new("762ddec2-25c9-4183-9891-72a19d84a839"); + public static readonly Guid ContributorId = new("e1101e2c-32cc-456f-9c82-4f1d1a65d141"); + public static readonly Guid DesignerId = new("f3ad41d3-ee90-4988-9195-8b2a8f4f2733"); + public static readonly Guid AdminId = new("fad1b19d-5333-4633-bd84-d67c64649f65"); + } + + public static class Users + { + public static readonly Guid CustomerId = new("e38c495f-b1f3-4226-d289-08dd11623eb9"); + public static readonly Guid ContributorId = new("af840410-f3f2-4a3b-d28a-08dd11623eb9"); + public static readonly Guid DesignerId = new("4337a774-2c5c-4c27-d28b-08dd11623eb9"); + public static readonly Guid AdminId = new("cb7749fb-3fff-4902-d28c-08dd11623eb9"); + } +} diff --git a/src/Modules/Identity/Infrastructure/GlobalUsings.cs b/src/Modules/Identity/Infrastructure/GlobalUsings.cs new file mode 100644 index 000000000..d01f7e4ac --- /dev/null +++ b/src/Modules/Identity/Infrastructure/GlobalUsings.cs @@ -0,0 +1 @@ +global using AppUserRole = Microsoft.AspNetCore.Identity.IdentityUserRole; diff --git a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRoles/Utilities.cs b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRoles/Utilities.cs index b1f99ca73..d30541b13 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRoles/Utilities.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRoles/Utilities.cs @@ -1,11 +1,8 @@ using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; -using CustomCADs.Shared.Domain; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Configurations.AppRoles; -using static DomainConstants.Roles; - internal static class Utilities { extension(EntityTypeBuilder builder) @@ -13,13 +10,37 @@ internal static class Utilities internal EntityTypeBuilder SetSeeding() { builder.HasData([ - new(Customer) { NormalizedName = Customer.ToUpperInvariant(), Id = new(CustomerId), ConcurrencyStamp = "51da1b9f-803c-4bd3-9a00-da7ac259ce32", }, - new(Contributor) { NormalizedName = Contributor.ToUpperInvariant(), Id = new(ContributorId), ConcurrencyStamp = "a1a170e0-ee84-4afe-afd9-1df57009f291", }, - new(Designer) { NormalizedName = Designer.ToUpperInvariant(), Id = new(DesignerId), ConcurrencyStamp = "1a8ba0a7-4853-42da-980d-3107784e7ab1", }, - new(Admin) { NormalizedName = Admin.ToUpperInvariant(), Id = new(AdminId), ConcurrencyStamp = "42174679-32f1-48b0-9524-0f00791ec760", }, + CreateAppRole( + id: Constants.Roles.CustomerId, + name: Shared.Domain.DomainConstants.Users.CustomerRole, + concurrencyStamp: "51da1b9f-803c-4bd3-9a00-da7ac259ce32" + ), + CreateAppRole( + id: Constants.Roles.ContributorId, + name: Shared.Domain.DomainConstants.Users.ContributorRole, + concurrencyStamp: "a1a170e0-ee84-4afe-afd9-1df57009f291" + ), + CreateAppRole( + id: Constants.Roles.DesignerId, + name: Shared.Domain.DomainConstants.Users.DesignerRole, + concurrencyStamp: "1a8ba0a7-4853-42da-980d-3107784e7ab1" + ), + CreateAppRole( + id: Constants.Roles.AdminId, + name: Shared.Domain.DomainConstants.Users.AdminRole, + concurrencyStamp: "42174679-32f1-48b0-9524-0f00791ec760" + ), ]); return builder; + + static AppRole CreateAppRole(Guid id, string name, string concurrencyStamp) + => new(name) + { + NormalizedName = name.ToUpperInvariant(), + Id = id, + ConcurrencyStamp = concurrencyStamp, + }; } } diff --git a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Configurations.cs b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Configurations.cs index 12adaf36b..1748998f1 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Configurations.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Configurations.cs @@ -3,8 +3,6 @@ namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Configurations.AppUserRoles; -using AppUserRole = Microsoft.AspNetCore.Identity.IdentityUserRole; - public class Configurations : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Utilities.cs b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Utilities.cs index 630d2aaab..f581d2062 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Utilities.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Utilities.cs @@ -1,10 +1,8 @@ -using CustomCADs.Shared.Domain; -using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Configurations.AppUserRoles; -using static DomainConstants; -using AppUserRole = Microsoft.AspNetCore.Identity.IdentityUserRole; +using static Infrastructure.Constants; internal static class Utilities { @@ -13,10 +11,10 @@ internal static class Utilities internal EntityTypeBuilder SetSeeding() { builder.HasData([ - new() { RoleId = new(Roles.CustomerId), UserId = new(Users.CustomerUserId) }, - new() { RoleId = new(Roles.ContributorId), UserId = new(Users.ContributorUserId) }, - new() { RoleId = new(Roles.DesignerId), UserId = new(Users.DesignerUserId) }, - new() { RoleId = new(Roles.AdminId), UserId = new(Users.AdminUserId) }, + new() { RoleId = Roles.CustomerId, UserId = Users.CustomerId }, + new() { RoleId = Roles.ContributorId, UserId = Users.ContributorId }, + new() { RoleId = Roles.DesignerId, UserId = Users.DesignerId }, + new() { RoleId = Roles.AdminId, UserId = Users.AdminId }, ]); return builder; diff --git a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUsers/Utilities.cs b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUsers/Utilities.cs index ac8999962..e92f40dc2 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUsers/Utilities.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUsers/Utilities.cs @@ -1,13 +1,10 @@ using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; -using CustomCADs.Shared.Domain; using CustomCADs.Shared.Domain.TypedIds.Accounts; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Configurations.AppUsers; -using static DomainConstants.Users; - internal static class Utilities { extension(EntityTypeBuilder builder) @@ -53,37 +50,37 @@ internal EntityTypeBuilder SetSeeding() { builder.HasData([ CreateAppUser( - id: new(CustomerUserId), - accountId: new(CustomerAccountId), - username: CustomerUsername, - email: CustomerEmail, + id: Constants.Users.CustomerId, + accountId: Shared.Domain.DomainConstants.Users.CustomerAccountId, + username: Shared.Domain.DomainConstants.Users.CustomerUsername, + email: Shared.Domain.DomainConstants.Users.CustomerEmail, passHash: "AQAAAAIAAYagAAAAEJFCGOTxNAgjhqU5lrA63WEtv924ujxXHt0x1R70qlS8dV9Pzz4II8GOgjVOaRzuDQ==", concStamp: "0c5bbfb2-d132-407b-9b1b-e1e640ccc14e", secStamp: "3A6TFN6VVZNRZEG22J777XJTPQY7342B" ), CreateAppUser( - id: new(ContributorUserId), - accountId: new(ContributorAccountId), - username: ContributorUsername, - email: ContributorEmail, + id: Constants.Users.ContributorId, + accountId: Shared.Domain.DomainConstants.Users.ContributorAccountId, + username: Shared.Domain.DomainConstants.Users.ContributorUsername, + email: Shared.Domain.DomainConstants.Users.ContributorEmail, passHash: "AQAAAAIAAYagAAAAEGjQ1Zes3r2XJgjoHQykiyr11OgUEDw+YDnOKeENyN7Kqi9RWKKRCtwd7ZtEyywdYA==", concStamp: "c77927de-61e7-4d53-be8d-a5390fafc75c", secStamp: "NWGZ3JTQSDNS346DMU7RP4IT4BDLHIQC" ), CreateAppUser( - id: new(DesignerUserId), - accountId: new(DesignerAccountId), - username: DesignerUsername, - email: DesignerEmail, + id: Constants.Users.DesignerId, + accountId: Shared.Domain.DomainConstants.Users.DesignerAccountId, + username: Shared.Domain.DomainConstants.Users.DesignerUsername, + email: Shared.Domain.DomainConstants.Users.DesignerEmail, passHash: "AQAAAAIAAYagAAAAEEUe31maWfuZY6V8MQBzUWKerMKobDukREinVfML3Yl2z+Nr6IIQZKvX4WKqbTUw6w==", concStamp: "c5940d6f-d5c0-4f84-a262-da9b07525c3c", secStamp: "FNNIT3NPOZKZK2E67WFLV5R3RGVBX7LV" ), CreateAppUser( - id: new(AdminUserId), - accountId: new(AdminAccountId), - username: AdminUsername, - email: AdminEmail, + id: Constants.Users.AdminId, + accountId: Shared.Domain.DomainConstants.Users.AdminAccountId, + username: Shared.Domain.DomainConstants.Users.AdminUsername, + email: Shared.Domain.DomainConstants.Users.AdminEmail, passHash: "AQAAAAIAAYagAAAAEFqtQ33BvarNRyFcmV4z48fPBlIY8zd0de90qq3Cdm1Row+2WRmEjVJk1yPadBkrSA==", concStamp: "5c94b43f-861c-4efa-a670-5627e49d354d", secStamp: "YIA26UZDSN2V2U5PVDEK4F3EJS3P5D3X" @@ -94,8 +91,8 @@ internal EntityTypeBuilder SetSeeding() } } - private static AppUser CreateAppUser(Guid id, string username, string email, string passHash, Guid accountId, string concStamp, string secStamp) - => new(username, email, AccountId.New(accountId)) + private static AppUser CreateAppUser(Guid id, string username, string email, string passHash, AccountId accountId, string concStamp, string secStamp) + => new(username, email, accountId) { Id = id, NormalizedUserName = username.ToUpperInvariant(), diff --git a/src/Modules/Identity/Infrastructure/Tokens/IdentityTokenService.cs b/src/Modules/Identity/Infrastructure/Tokens/IdentityTokenService.cs index 966b3fa7a..c749d2d5e 100644 --- a/src/Modules/Identity/Infrastructure/Tokens/IdentityTokenService.cs +++ b/src/Modules/Identity/Infrastructure/Tokens/IdentityTokenService.cs @@ -2,7 +2,6 @@ using CustomCADs.Modules.Identity.Application.Users.Dtos; using CustomCADs.Modules.Identity.Domain.Users; using CustomCADs.Modules.Identity.Domain.Users.Entities; -using CustomCADs.Shared.Domain; using CustomCADs.Shared.Domain.TypedIds.Accounts; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; @@ -13,7 +12,7 @@ namespace CustomCADs.Modules.Identity.Infrastructure.Tokens; -using static DomainConstants.Tokens; +using static Domain.Constants.Tokens; public sealed class IdentityTokenService(IOptions jwtOptions) : ITokenService { diff --git a/src/Modules/Notifications/API/Notifications/Endpoints/NotificationsGroup.cs b/src/Modules/Notifications/API/Notifications/Endpoints/NotificationsGroup.cs index 66e6e2745..3c182583e 100644 --- a/src/Modules/Notifications/API/Notifications/Endpoints/NotificationsGroup.cs +++ b/src/Modules/Notifications/API/Notifications/Endpoints/NotificationsGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Notifications.API.Notifications.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class NotificationsGroup : Group { @@ -9,7 +9,7 @@ public NotificationsGroup() { Configure(Paths.Notifications, x => { - x.Roles(Customer, Contributor, Designer, Admin); + x.Roles(CustomerRole, ContributorRole, DesignerRole, AdminRole); x.Description(x => x.WithTags(Tags[Paths.Notifications])); }); } diff --git a/src/Modules/Printing/API/Materials/Endpoints/MaterialsGroup.cs b/src/Modules/Printing/API/Materials/Endpoints/MaterialsGroup.cs index 275432996..deb286946 100644 --- a/src/Modules/Printing/API/Materials/Endpoints/MaterialsGroup.cs +++ b/src/Modules/Printing/API/Materials/Endpoints/MaterialsGroup.cs @@ -1,7 +1,7 @@ namespace CustomCADs.Modules.Printing.API.Materials.Endpoints; using static APIConstants; -using static DomainConstants.Roles; +using static DomainConstants.Users; public class MaterialsGroup : Group { @@ -9,7 +9,7 @@ public MaterialsGroup() { Configure(Paths.Materials, x => { - x.Roles(Admin); + x.Roles(AdminRole); x.Description(x => x.WithTags(Tags[Paths.Materials])); }); } diff --git a/src/Modules/Printing/Application/Materials/Policies/MaterialTextureReplacePolicy.cs b/src/Modules/Printing/Application/Materials/Policies/MaterialTextureReplacePolicy.cs index 8b1088f0e..97f7b233f 100644 --- a/src/Modules/Printing/Application/Materials/Policies/MaterialTextureReplacePolicy.cs +++ b/src/Modules/Printing/Application/Materials/Policies/MaterialTextureReplacePolicy.cs @@ -16,7 +16,7 @@ public async Task EnsureReplaceGrantedAsync(IFileReplacePolicy.FileCont query: new GetUserRoleByIdQuery(context.CallerId) ).ConfigureAwait(false); - if (role is not DomainConstants.Roles.Admin) + if (role is not DomainConstants.Users.AdminRole) { throw CustomAuthorizationException.ById(context.FileId, "Texture"); } diff --git a/src/Modules/Printing/Application/Materials/Policies/MaterialTextureUploadPolicy.cs b/src/Modules/Printing/Application/Materials/Policies/MaterialTextureUploadPolicy.cs index e9c243e3b..ff6e38e17 100644 --- a/src/Modules/Printing/Application/Materials/Policies/MaterialTextureUploadPolicy.cs +++ b/src/Modules/Printing/Application/Materials/Policies/MaterialTextureUploadPolicy.cs @@ -16,7 +16,7 @@ public async Task EnsureUploadGrantedAsync(IFileUploadPolicy.FileContex query: new GetUserRoleByIdQuery(context.CallerId) ).ConfigureAwait(false); - if (role is not DomainConstants.Roles.Admin) + if (role is not DomainConstants.Users.AdminRole) { throw CustomAuthorizationException.Custom("Only Admins can upload a Material's Texture"); } diff --git a/src/Presentation/Program.cs b/src/Presentation/Program.cs index 99e661500..73ed104d1 100644 --- a/src/Presentation/Program.cs +++ b/src/Presentation/Program.cs @@ -1,14 +1,14 @@ using CustomCADs.Modules.Identity.API; using CustomCADs.Presentation; using CustomCADs.Shared.API; -using static CustomCADs.Shared.Domain.DomainConstants.Roles; +using CustomCADs.Shared.Domain; var builder = WebApplication.CreateBuilder(args); // Neccessities builder.Services.AddCorsForClient(builder.Configuration); builder.Services.AddAuthN().AddJwt(builder.Configuration).AddSSO(builder.Configuration, APIConstants.SSO.Providers); -builder.Services.AddAuthZ(Customer, Contributor, Designer, Admin); +builder.Services.AddAuthZ(DomainConstants.Users.Roles); // Use Cases builder.Services.AddUseCases(builder.Environment); diff --git a/src/Shared/Domain/DomainConstants.cs b/src/Shared/Domain/DomainConstants.cs index fc966fa50..c81f264fc 100644 --- a/src/Shared/Domain/DomainConstants.cs +++ b/src/Shared/Domain/DomainConstants.cs @@ -1,4 +1,5 @@ -using CustomCADs.Shared.Domain.TypedIds.Catalog; +using CustomCADs.Shared.Domain.TypedIds.Accounts; +using CustomCADs.Shared.Domain.TypedIds.Catalog; using CustomCADs.Shared.Domain.TypedIds.Files; using System.Text.RegularExpressions; @@ -40,35 +41,18 @@ public static class Textures public static readonly ImageId Wood = ImageId.New(Guid.Parse("3fe2472c-d2c6-434c-a013-ef117319bed3")); } - public static class Roles - { - public const string CustomerId = "762ddec2-25c9-4183-9891-72a19d84a839"; - public const string ContributorId = "e1101e2c-32cc-456f-9c82-4f1d1a65d141"; - public const string DesignerId = "f3ad41d3-ee90-4988-9195-8b2a8f4f2733"; - public const string AdminId = "fad1b19d-5333-4633-bd84-d67c64649f65"; - - public const string Customer = "Customer"; - public const string Contributor = "Contributor"; - public const string Designer = "Designer"; - public const string Admin = "Administrator"; - - public const string CustomerDescription = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments."; - public const string ContributorDescription = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself."; - public const string DesignerDescription = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do."; - public const string AdminDescription = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles."; - } - public static class Users { - public const string CustomerUserId = "e38c495f-b1f3-4226-d289-08dd11623eb9"; - public const string ContributorUserId = "af840410-f3f2-4a3b-d28a-08dd11623eb9"; - public const string DesignerUserId = "4337a774-2c5c-4c27-d28b-08dd11623eb9"; - public const string AdminUserId = "cb7749fb-3fff-4902-d28c-08dd11623eb9"; + public static readonly AccountId CustomerAccountId = AccountId.New("2da61b05-1a27-4af9-9df2-be4f1f4e835f"); + public static readonly AccountId ContributorAccountId = AccountId.New("6d963818-23dc-4e9a-aaa8-b4c77252bc97"); + public static readonly AccountId DesignerAccountId = AccountId.New("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"); + public static readonly AccountId AdminAccountId = AccountId.New("e995039c-a535-4f20-8288-7aadcb71b252"); - public const string CustomerAccountId = "2da61b05-1a27-4af9-9df2-be4f1f4e835f"; - public const string ContributorAccountId = "6d963818-23dc-4e9a-aaa8-b4c77252bc97"; - public const string DesignerAccountId = "0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"; - public const string AdminAccountId = "e995039c-a535-4f20-8288-7aadcb71b252"; + public static readonly string[] Roles = [CustomerRole, ContributorRole, DesignerRole, AdminRole]; + public const string CustomerRole = "Customer"; + public const string ContributorRole = "Contributor"; + public const string DesignerRole = "Designer"; + public const string AdminRole = "Administrator"; public const string CustomerUsername = "For7a7a"; public const string ContributorUsername = "PDMatsaliev20"; @@ -80,11 +64,4 @@ public static class Users public const string DesignerEmail = "boriskolev2006@gmail.com"; public const string AdminEmail = "ivanangelov414@gmail.com"; } - - public static class Tokens - { - public const int JwtDurationInMinutes = 15; - public const int RtDurationInDays = 7; - public const int LongerRtDurationInDays = 15; - } } diff --git a/tests/UnitTests/Accounts/Application/Accounts/Commands/Internal/Create/CreateAccountHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Commands/Internal/Create/CreateAccountHandlerUnitTests.cs index bde977271..29a13a445 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Commands/Internal/Create/CreateAccountHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Commands/Internal/Create/CreateAccountHandlerUnitTests.cs @@ -8,7 +8,6 @@ namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Commands.Internal.Create; using static AccountsData; -using static DomainConstants; public class CreateAccountHandlerUnitTests : AccountsBaseUnitTests { @@ -23,7 +22,7 @@ public CreateAccountHandlerUnitTests() writes.Setup(x => x.AddAsync( It.Is(x => - x.RoleName == Roles.Customer + x.RoleName == ValidRole && x.Username == ValidUsername && x.Email == ValidEmail1 && x.FirstName == ValidFirstName @@ -38,7 +37,7 @@ public async Task Handle_ShouldPersistToDatabase() { // Arrange CreateAccountCommand command = new( - Role: Roles.Customer, + Role: ValidRole, Username: ValidUsername, Email: ValidEmail1, Password: ValidPassword, @@ -52,7 +51,7 @@ public async Task Handle_ShouldPersistToDatabase() // Assert writes.Verify(x => x.AddAsync( It.Is(x => - x.RoleName == Roles.Customer + x.RoleName == ValidRole && x.Username == ValidUsername && x.Email == ValidEmail1 && x.FirstName == ValidFirstName @@ -68,7 +67,7 @@ public async Task Handle_ShouldRaiseEvents() { // Arrange CreateAccountCommand command = new( - Role: Roles.Customer, + Role: ValidRole, Username: ValidUsername, Email: ValidEmail1, Password: ValidPassword, @@ -95,7 +94,7 @@ public async Task Handle_ShouldReturnResult() { // Arrange CreateAccountCommand command = new( - Role: Roles.Customer, + Role: ValidRole, Username: ValidUsername, Email: ValidEmail1, Password: ValidPassword, diff --git a/tests/UnitTests/Accounts/Application/Accounts/Commands/Shared/Create/CreateAccountHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Commands/Shared/Create/CreateAccountHandlerUnitTests.cs index 932c27f4f..b10fd1526 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Commands/Shared/Create/CreateAccountHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Commands/Shared/Create/CreateAccountHandlerUnitTests.cs @@ -4,10 +4,10 @@ using CustomCADs.Shared.Application.UseCases.Accounts.Commands; using CustomCADs.Shared.Domain.TypedIds.Accounts; -namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Commands.Shared.Create; +using static CustomCADs.UnitTests.Accounts.Data.AccountsData; +using static CustomCADs.Shared.Domain.DomainConstants; -using static AccountsData; -using static DomainConstants; +namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Commands.Shared.Create; public class CreateAccountHandlerUnitTests : AccountsBaseUnitTests { @@ -21,7 +21,7 @@ public CreateAccountHandlerUnitTests() writes.Setup(x => x.AddAsync( It.Is(x => - x.RoleName == Roles.Customer + x.RoleName == ValidRole && x.Username == ValidUsername && x.Email == ValidEmail1 && x.FirstName == ValidFirstName @@ -36,7 +36,7 @@ public async Task Handle_ShouldPersistToDatabase() { // Arrange CreateAccountCommand command = new( - Role: Roles.Customer, + Role: ValidRole, Username: ValidUsername, Email: ValidEmail1, FirstName: ValidFirstName, @@ -49,7 +49,7 @@ public async Task Handle_ShouldPersistToDatabase() // Assert writes.Verify(x => x.AddAsync( It.Is(x => - x.RoleName == Roles.Customer + x.RoleName == ValidRole && x.Username == ValidUsername && x.Email == ValidEmail1 && x.FirstName == ValidFirstName @@ -65,7 +65,7 @@ public async Task Handle_ShouldReturnResult() { // Arrange CreateAccountCommand command = new( - Role: Roles.Customer, + Role: ValidRole, Username: ValidUsername, Email: ValidEmail1, FirstName: ValidFirstName, diff --git a/tests/UnitTests/Accounts/Application/Accounts/Queries/Internal/GetAll/GetAllAccountsHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Queries/Internal/GetAll/GetAllAccountsHandlerUnitTests.cs index 2f685f6c0..6eb9987f7 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Queries/Internal/GetAll/GetAllAccountsHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Queries/Internal/GetAll/GetAllAccountsHandlerUnitTests.cs @@ -5,19 +5,16 @@ namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Queries.Internal.GetAll; -using static DomainConstants; -using static DomainConstants.Users; - public class GetAllAccountsHandlerUnitTests : AccountsBaseUnitTests { private readonly GetAllAccountsHandler handler; private readonly Mock reads = new(); private readonly Account[] accounts = [ - Account.CreateWithId(AccountId.New(), Roles.Customer, CustomerUsername, CustomerEmail, DateTimeOffset.UtcNow), - Account.CreateWithId(AccountId.New(), Roles.Contributor, ContributorUsername, ContributorEmail, DateTimeOffset.UtcNow), - Account.CreateWithId(AccountId.New(), Roles.Designer, DesignerUsername, DesignerEmail, DateTimeOffset.UtcNow), - Account.CreateWithId(AccountId.New(), Roles.Admin, AdminUsername, AdminEmail, DateTimeOffset.UtcNow), + CreateAccountWithId(id: AccountId.New()), + CreateAccountWithId(id: AccountId.New()), + CreateAccountWithId(id: AccountId.New()), + CreateAccountWithId(id: AccountId.New()), ]; private readonly AccountQuery query = new(Pagination: new(1, 1)); diff --git a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetUsernames/GetUsernamesByIdsHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetUsernames/GetUsernamesByIdsHandlerUnitTests.cs index f6a2a7495..ba6588039 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetUsernames/GetUsernamesByIdsHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetUsernames/GetUsernamesByIdsHandlerUnitTests.cs @@ -7,7 +7,6 @@ namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Queries.Shared.GetUsernames; using static AccountsData; -using static DomainConstants.Roles; using static DomainConstants.Users; public class GetUsernamesByIdsHandlerUnitTests : AccountsBaseUnitTests @@ -26,10 +25,10 @@ public GetUsernamesByIdsHandlerUnitTests() reads.Setup(x => x.AllAsync(accountQuery, false, ct)).ReturnsAsync(new Result( Count: ids.Length, Items: [ - CreateAccountWithId(AccountId.New(), Customer, CustomerUsername), - CreateAccountWithId(AccountId.New(), Contributor, ContributorUsername), - CreateAccountWithId(AccountId.New(), Designer, DesignerUsername), - CreateAccountWithId(AccountId.New(), Admin, AdminUsername), + CreateAccountWithId(id: AccountId.New(), username: CustomerUsername), + CreateAccountWithId(id: AccountId.New(), username: ContributorUsername), + CreateAccountWithId(id: AccountId.New(), username: DesignerUsername), + CreateAccountWithId(id: AccountId.New(), username: AdminUsername), ] )); } diff --git a/tests/UnitTests/Accounts/Application/Roles/Queries/Internal/GetAll/GetAllRolesHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Roles/Queries/Internal/GetAll/GetAllRolesHandlerUnitTests.cs index 5ab563f3b..081c1afe9 100644 --- a/tests/UnitTests/Accounts/Application/Roles/Queries/Internal/GetAll/GetAllRolesHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Roles/Queries/Internal/GetAll/GetAllRolesHandlerUnitTests.cs @@ -5,8 +5,6 @@ namespace CustomCADs.UnitTests.Accounts.Application.Roles.Queries.Internal.GetAll; -using static DomainConstants.Roles; - public class GetAllRolesHandlerUnitTests : RolesBaseUnitTests { private readonly GetAllRolesHandler handler; @@ -14,10 +12,10 @@ public class GetAllRolesHandlerUnitTests : RolesBaseUnitTests private readonly Mock> cache = new(); private readonly Role[] roles = [ - Role.CreateWithId(RoleId.New(), Customer, CustomerDescription), - Role.CreateWithId(RoleId.New(), Contributor, ContributorDescription), - Role.CreateWithId(RoleId.New(), Designer, DesignerDescription), - Role.CreateWithId(RoleId.New(), Admin, AdminDescription), + CreateRoleWithId(), + CreateRoleWithId(), + CreateRoleWithId(), + CreateRoleWithId(), ]; public GetAllRolesHandlerUnitTests() diff --git a/tests/UnitTests/Accounts/Application/Roles/RolesBaseUnitTests.cs b/tests/UnitTests/Accounts/Application/Roles/RolesBaseUnitTests.cs index fda56f06e..3c43c5b22 100644 --- a/tests/UnitTests/Accounts/Application/Roles/RolesBaseUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Roles/RolesBaseUnitTests.cs @@ -8,8 +8,8 @@ public class RolesBaseUnitTests public static readonly CancellationToken ct = CancellationToken.None; protected static Role CreateRole(string? name = null, string? description = null) - => Role.Create(name ?? ValidName, MinValidDescription); + => Role.Create(name ?? ValidName, description ?? MinValidDescription); protected static Role CreateRoleWithId(RoleId? id = null, string? name = null, string? description = null) - => Role.CreateWithId(id ?? ValidId, name ?? ValidName, MinValidDescription); + => Role.CreateWithId(id ?? ValidId, name ?? ValidName, description ?? MinValidDescription); } diff --git a/tests/UnitTests/Accounts/Data/AccountsData.cs b/tests/UnitTests/Accounts/Data/AccountsData.cs index f6bb3e515..19ae29520 100644 --- a/tests/UnitTests/Accounts/Data/AccountsData.cs +++ b/tests/UnitTests/Accounts/Data/AccountsData.cs @@ -19,7 +19,6 @@ public static class AccountsData public const string ValidEmail1 = CustomerEmail; public const string ValidEmail2 = ContributorEmail; public const string ValidEmail3 = DesignerEmail; - public const string ValidEmail4 = AdminEmail; public const string InvalidEmail = ""; public const string InvalidEmailLocal = "@domain.tld"; public const string InvalidEmailDomain = "local@"; @@ -36,6 +35,7 @@ public static class AccountsData public static readonly string MinInvalidLastName = new('a', NameMinLength - 1); public static readonly string MaxInvalidLastName = new('a', NameMaxLength + 1); + public const string ValidRole = "role123"; public const string ValidPassword = "password123"; - public static readonly AccountId ValidId = AccountId.New(CustomerAccountId); + public static readonly AccountId ValidId = AccountId.New(); } diff --git a/tests/UnitTests/Accounts/Data/RolesData.cs b/tests/UnitTests/Accounts/Data/RolesData.cs index 0fbd8c1de..70df47815 100644 --- a/tests/UnitTests/Accounts/Data/RolesData.cs +++ b/tests/UnitTests/Accounts/Data/RolesData.cs @@ -1,15 +1,15 @@ using CustomCADs.Modules.Accounts.Domain.Roles; -using CustomCADs.Shared.Domain; +using CustomCADs.Modules.Accounts.Domain; using CustomCADs.Shared.Domain.TypedIds.Accounts; namespace CustomCADs.UnitTests.Accounts.Data; -using static DomainConstants.Roles; +using static Constants.Roles; using static RoleConstants; public static class RolesData { - public static readonly string ValidName = Customer; + public static readonly string ValidName = Shared.Domain.DomainConstants.Users.CustomerRole; public static readonly string MinValidName = new('a', NameMinLength + 1); public static readonly string MaxValidName = new('a', NameMaxLength - 1); public const string InvalidName = ""; diff --git a/tests/UnitTests/Accounts/Domain/Accounts/Behaviors/Email/Data/AccountEmailValidData.cs b/tests/UnitTests/Accounts/Domain/Accounts/Behaviors/Email/Data/AccountEmailValidData.cs index bafdb65ab..833ee48a3 100644 --- a/tests/UnitTests/Accounts/Domain/Accounts/Behaviors/Email/Data/AccountEmailValidData.cs +++ b/tests/UnitTests/Accounts/Domain/Accounts/Behaviors/Email/Data/AccountEmailValidData.cs @@ -9,6 +9,5 @@ public AccountEmailValidData() Add(ValidEmail1); Add(ValidEmail2); Add(ValidEmail3); - Add(ValidEmail4); } } diff --git a/tests/UnitTests/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandlerUnitTests.cs b/tests/UnitTests/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandlerUnitTests.cs index 6b8b1788e..9617f2ff6 100644 --- a/tests/UnitTests/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandlerUnitTests.cs +++ b/tests/UnitTests/Catalog/Application/Products/Commands/Internal/Creator/Create/CreateProductHandlerUnitTests.cs @@ -47,7 +47,7 @@ public CreateProductHandlerUnitTests() sender.Setup(x => x.SendQueryAsync( It.Is(x => x.Id == ValidCreatorId), ct - )).ReturnsAsync(DomainConstants.Roles.Contributor); + )).ReturnsAsync("role"); sender.Setup(x => x.SendQueryAsync( It.Is(x => x.Id == ValidCadId), ct @@ -186,7 +186,7 @@ public async Task Handle_ShouldValidateStatus_WhenDesignerRole() sender.Setup(x => x.SendQueryAsync( It.Is(x => x.Id == ValidCreatorId), ct - )).ReturnsAsync(DomainConstants.Roles.Designer); + )).ReturnsAsync(DomainConstants.Users.DesignerRole); CreateProductCommand command = new( Name: MinValidName, @@ -212,7 +212,7 @@ public async Task Handle_ShouldTagProfessional_WhenDesignerRole() sender.Setup(x => x.SendQueryAsync( It.Is(x => x.Id == ValidCreatorId), ct - )).ReturnsAsync(DomainConstants.Roles.Designer); + )).ReturnsAsync(DomainConstants.Users.DesignerRole); CreateProductCommand command = new( Name: MinValidName, diff --git a/tests/UnitTests/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandlerUnitTests.cs b/tests/UnitTests/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandlerUnitTests.cs index 27693c724..0f0675ac6 100644 --- a/tests/UnitTests/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandlerUnitTests.cs +++ b/tests/UnitTests/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandlerUnitTests.cs @@ -3,7 +3,6 @@ using CustomCADs.Modules.Delivery.Domain.Repositories.Reads; using CustomCADs.Shared.Application.Exceptions; using CustomCADs.Shared.Domain; -using CustomCADs.Shared.Domain.TypedIds.Accounts; namespace CustomCADs.UnitTests.Delivery.Application.Shipments.Queries.Internal.GetWaybill; @@ -17,7 +16,6 @@ public class GetShipmentWaybillHandlerUnitTests : ShipmentsBaseUnitTests private readonly Mock delivery = new(); private static readonly byte[] bytes = [1, 2, 3, 4, 5, 6]; - private static readonly AccountId headDesignerId = AccountId.New(DesignerAccountId); public GetShipmentWaybillHandlerUnitTests() { @@ -33,7 +31,7 @@ public GetShipmentWaybillHandlerUnitTests() public async Task Handle_ShouldQueryDatabase() { // Arrange - GetShipmentWaybillQuery query = new(ValidId, headDesignerId); + GetShipmentWaybillQuery query = new(ValidId, HeadDesignerAccountId); // Act await handler.Handle(query, ct); @@ -49,7 +47,7 @@ public async Task Handle_ShouldQueryDatabase() public async Task Handle_ShouldCallDelivery() { // Arrange - GetShipmentWaybillQuery query = new(ValidId, headDesignerId); + GetShipmentWaybillQuery query = new(ValidId, HeadDesignerAccountId); // Act await handler.Handle(query, ct); @@ -62,7 +60,7 @@ public async Task Handle_ShouldCallDelivery() public async Task Handle_ShouldReturnResult() { // Arrange - GetShipmentWaybillQuery query = new(ValidId, headDesignerId); + GetShipmentWaybillQuery query = new(ValidId, HeadDesignerAccountId); // Act byte[] result = await handler.Handle(query, ct); @@ -89,7 +87,7 @@ public async Task Handle_ShouldThrowException_WhenShipmentStatusInvalid() { // Arrange reads.Setup(x => x.SingleByIdAsync(ValidId, false, ct)).ReturnsAsync(CreateShipment()); - GetShipmentWaybillQuery query = new(ValidId, headDesignerId); + GetShipmentWaybillQuery query = new(ValidId, HeadDesignerAccountId); // Assert await Assert.ThrowsAsync>( diff --git a/tests/UnitTests/Delivery/Data/ShipmentsData.cs b/tests/UnitTests/Delivery/Data/ShipmentsData.cs index e788b7778..e383e04c3 100644 --- a/tests/UnitTests/Delivery/Data/ShipmentsData.cs +++ b/tests/UnitTests/Delivery/Data/ShipmentsData.cs @@ -44,5 +44,5 @@ public static class ShipmentsData public const string ValidReferenceId = "some-reference-id"; public static readonly ShipmentId ValidId = ShipmentId.New(); - public static readonly AccountId ValidBuyerId = AccountId.New(CustomerAccountId); + public static readonly AccountId ValidBuyerId = AccountId.New(); } diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandlerUnitTests.cs index 2f9fbe0b2..3b6f65012 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandlerUnitTests.cs @@ -3,11 +3,10 @@ using CustomCADs.Modules.Identity.Application.Users.Dtos; using CustomCADs.Modules.Identity.Domain.Users.Entities; using CustomCADs.Shared.Application.Exceptions; -using CustomCADs.Shared.Domain; +using static CustomCADs.Modules.Identity.Domain.Constants.Tokens; namespace CustomCADs.UnitTests.Identity.Application.Users.Commands.Internal.Refresh; -using static DomainConstants.Tokens; using static UsersData; public class RefreshUserHandlerUnitTests : UsersBaseUnitTests diff --git a/tests/UnitTests/Identity/Data/UsersData.cs b/tests/UnitTests/Identity/Data/UsersData.cs index 0652f4242..1974327aa 100644 --- a/tests/UnitTests/Identity/Data/UsersData.cs +++ b/tests/UnitTests/Identity/Data/UsersData.cs @@ -10,7 +10,7 @@ namespace CustomCADs.UnitTests.Identity.Data; public static class UsersData { - public const string ValidRole = Roles.Customer; + public const string ValidRole = Users.CustomerRole; public static readonly string InvalidRole = string.Empty; public static readonly string MinValidUsername = new('a', UsernameMinLength + 1); diff --git a/tests/UnitTests/Identity/Domain/Users/Behaviors/AddRefreshToken/UserAddRefreshTokenUnitTests.cs b/tests/UnitTests/Identity/Domain/Users/Behaviors/AddRefreshToken/UserAddRefreshTokenUnitTests.cs index 9e11f18d1..f12fffae7 100644 --- a/tests/UnitTests/Identity/Domain/Users/Behaviors/AddRefreshToken/UserAddRefreshTokenUnitTests.cs +++ b/tests/UnitTests/Identity/Domain/Users/Behaviors/AddRefreshToken/UserAddRefreshTokenUnitTests.cs @@ -1,10 +1,8 @@ using CustomCADs.Modules.Identity.Domain.Users.Entities; -using CustomCADs.Shared.Domain; +using static CustomCADs.Modules.Identity.Domain.Constants; namespace CustomCADs.UnitTests.Identity.Domain.Users.Behaviors.AddRefreshToken; -using static DomainConstants; - public class UserAddRefreshTokenUnitTests : UsersBaseUnitTests { private const string Value = "refresh-token"; From 431198683707926d3f2cc44990c24e0731a3fcce Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 25 Apr 2026 01:28:57 +0300 Subject: [PATCH 06/22] Added Head Designer seed and logic --- .../Configurations/Accounts/Utilities.cs | 1 + ...313_Added_HeadDesigner_Account.Designer.cs | 204 ++++++++ ...260424222313_Added_HeadDesigner_Account.cs | 46 ++ .../AccountsContextModelSnapshot.cs | 13 +- .../GetWaybill/GetShipmentWaybillHandler.cs | 2 +- .../Identity/Infrastructure/Constants.cs | 3 +- .../Configurations/AppUserRoles/Utilities.cs | 1 + .../Configurations/AppUsers/Utilities.cs | 9 + ...222719_Added_HeadDesigner_User.Designer.cs | 483 ++++++++++++++++++ .../20260424222719_Added_HeadDesigner_User.cs | 57 +++ .../IdentityContextModelSnapshot.cs | 29 +- src/Shared/Domain/DomainConstants.cs | 9 +- .../GetUsernamesByIdsHandlerUnitTests.cs | 3 +- 13 files changed, 849 insertions(+), 11 deletions(-) create mode 100644 src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.Designer.cs create mode 100644 src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.cs create mode 100644 src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs create mode 100644 src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.cs diff --git a/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs b/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs index ddb347a73..a54d55cfe 100644 --- a/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs +++ b/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs @@ -78,6 +78,7 @@ internal EntityTypeBuilder SetSeeding() Account.CreateWithId(CustomerAccountId, CustomerRole, CustomerUsername, CustomerEmail, new DateTimeOffset(2025, 05, 10, 19, 23, 12, 123, TimeSpan.FromHours(3))), Account.CreateWithId(ContributorAccountId, ContributorRole, ContributorUsername, ContributorEmail, new DateTimeOffset(2025, 05, 13, 17, 42, 57, 456, TimeSpan.FromHours(3))), Account.CreateWithId(DesignerAccountId, DesignerRole, DesignerUsername, DesignerEmail, new DateTimeOffset(2025, 01, 09, 13, 15, 28, 789, TimeSpan.FromHours(3))), + Account.CreateWithId(HeadDesignerAccountId, DesignerRole, HeadDesignerUsername, HeadDesignerEmail, new DateTimeOffset(2024, 03, 17, 02, 17, 32, 789, TimeSpan.FromHours(3))), Account.CreateWithId(AdminAccountId, AdminRole, AdminUsername, AdminEmail, new DateTimeOffset(2024, 03, 17, 02, 45, 13, 000, TimeSpan.FromHours(3))), ]); diff --git a/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.Designer.cs b/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.Designer.cs new file mode 100644 index 000000000..abc4bc0a3 --- /dev/null +++ b/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.Designer.cs @@ -0,0 +1,204 @@ +// +using System; +using CustomCADs.Modules.Accounts.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CustomCADs.Modules.Accounts.Persistence.Migrations +{ + [DbContext(typeof(AccountsContext))] + [Migration("20260424222313_Added_HeadDesigner_Account")] + partial class Added_HeadDesigner_Account + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Accounts") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Email"); + + b.Property("FirstName") + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("FirstName"); + + b.Property("LastName") + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("LastName"); + + b.Property("RoleName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("RoleName"); + + b.Property("TrackViewedProducts") + .HasColumnType("boolean") + .HasColumnName("TrackViewedProducts"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("Username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Accounts", "Accounts"); + + b.HasData( + new + { + Id = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 10, 19, 23, 12, 123, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "ivanzlatinov006@gmail.com", + RoleName = "Customer", + TrackViewedProducts = true, + Username = "For7a7a" + }, + new + { + Id = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 13, 17, 42, 57, 456, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "PDMatsaliev20@codingburgas.bg", + RoleName = "Contributor", + TrackViewedProducts = true, + Username = "PDMatsaliev20" + }, + new + { + Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "john.cad@gmail.com", + RoleName = "Designer", + TrackViewedProducts = true, + Username = "John_CAD" + }, + new + { + Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "boriskolev2006@gmail.com", + RoleName = "Designer", + TrackViewedProducts = true, + Username = "Oracle3000" + }, + new + { + Id = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"), + CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 45, 13, 0, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "ivanangelov414@gmail.com", + RoleName = "Administrator", + TrackViewedProducts = true, + Username = "NinjataBG" + }); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("Description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Name"); + + b.HasKey("Id"); + + b.ToTable("Roles", "Accounts"); + + b.HasData( + new + { + Id = 1, + Description = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments.", + Name = "Customer" + }, + new + { + Id = 2, + Description = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself.", + Name = "Contributor" + }, + new + { + Id = 3, + Description = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do.", + Name = "Designer" + }, + new + { + Id = 4, + Description = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles.", + Name = "Administrator" + }); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.HasKey("AccountId", "ProductId"); + + b.ToTable("ViewedProducts", "Accounts"); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b => + { + b.HasOne("CustomCADs.Modules.Accounts.Domain.Accounts.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.cs b/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.cs new file mode 100644 index 000000000..6a0d3505d --- /dev/null +++ b/src/Modules/Accounts/Persistence/Migrations/20260424222313_Added_HeadDesigner_Account.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + + +namespace CustomCADs.Modules.Accounts.Persistence.Migrations; + +/// +public partial class Added_HeadDesigner_Account : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: "Accounts", + table: "Accounts", + keyColumn: "Id", + keyValue: new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + column: "CreatedAt", + value: new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0))); + + migrationBuilder.InsertData( + schema: "Accounts", + table: "Accounts", + columns: new[] { "Id", "CreatedAt", "Email", "FirstName", "LastName", "RoleName", "TrackViewedProducts", "Username" }, + values: new object[] { new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), "john.cad@gmail.com", null, null, "Designer", true, "John_CAD" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + schema: "Accounts", + table: "Accounts", + keyColumn: "Id", + keyValue: new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1")); + + migrationBuilder.UpdateData( + schema: "Accounts", + table: "Accounts", + keyColumn: "Id", + keyValue: new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + column: "CreatedAt", + value: new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0))); + } +} diff --git a/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs b/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs index c4d019dfb..695326ed5 100644 --- a/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs +++ b/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Accounts") - .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -94,8 +94,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) }, new { - Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "john.cad@gmail.com", + RoleName = "Designer", + TrackViewedProducts = true, + Username = "John_CAD" + }, + new + { + Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), Email = "boriskolev2006@gmail.com", RoleName = "Designer", TrackViewedProducts = true, diff --git a/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs b/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs index 647c3b4e7..f6150d019 100644 --- a/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs +++ b/src/Modules/Delivery/Application/Shipments/Queries/Internal/GetWaybill/GetShipmentWaybillHandler.cs @@ -16,7 +16,7 @@ public async Task Handle(GetShipmentWaybillQuery req, CancellationToken Shipment shipment = await reads.SingleByIdAsync(req.Id, track: false, ct: ct).ConfigureAwait(false) ?? throw CustomNotFoundException.ById(req.Id); - if (req.CallerId != DesignerAccountId) + if (req.CallerId != HeadDesignerAccountId) { throw CustomAuthorizationException.ById(req.Id); } diff --git a/src/Modules/Identity/Infrastructure/Constants.cs b/src/Modules/Identity/Infrastructure/Constants.cs index 790a99303..2400d76fe 100644 --- a/src/Modules/Identity/Infrastructure/Constants.cs +++ b/src/Modules/Identity/Infrastructure/Constants.cs @@ -14,7 +14,8 @@ public static class Users { public static readonly Guid CustomerId = new("e38c495f-b1f3-4226-d289-08dd11623eb9"); public static readonly Guid ContributorId = new("af840410-f3f2-4a3b-d28a-08dd11623eb9"); - public static readonly Guid DesignerId = new("4337a774-2c5c-4c27-d28b-08dd11623eb9"); + public static readonly Guid DesignerId = new("a8145f5f-a3a4-4f06-9461-9f24b9f23fde"); + public static readonly Guid HeadDesignerId = new("4337a774-2c5c-4c27-d28b-08dd11623eb9"); public static readonly Guid AdminId = new("cb7749fb-3fff-4902-d28c-08dd11623eb9"); } } diff --git a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Utilities.cs b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Utilities.cs index f581d2062..d6cd92ed6 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Utilities.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUserRoles/Utilities.cs @@ -14,6 +14,7 @@ internal EntityTypeBuilder SetSeeding() new() { RoleId = Roles.CustomerId, UserId = Users.CustomerId }, new() { RoleId = Roles.ContributorId, UserId = Users.ContributorId }, new() { RoleId = Roles.DesignerId, UserId = Users.DesignerId }, + new() { RoleId = Roles.DesignerId, UserId = Users.HeadDesignerId }, new() { RoleId = Roles.AdminId, UserId = Users.AdminId }, ]); diff --git a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUsers/Utilities.cs b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUsers/Utilities.cs index e92f40dc2..252c6ff19 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUsers/Utilities.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppUsers/Utilities.cs @@ -76,6 +76,15 @@ internal EntityTypeBuilder SetSeeding() concStamp: "c5940d6f-d5c0-4f84-a262-da9b07525c3c", secStamp: "FNNIT3NPOZKZK2E67WFLV5R3RGVBX7LV" ), + CreateAppUser( + id: Constants.Users.HeadDesignerId, + accountId: Shared.Domain.DomainConstants.Users.HeadDesignerAccountId, + username: Shared.Domain.DomainConstants.Users.HeadDesignerUsername, + email: Shared.Domain.DomainConstants.Users.HeadDesignerEmail, + passHash: "AQAAAAIAAYagAAAAEJGRCbKUsSh9BwxPXoIQRG1AVXYDfmWsY5vEA4aEnqBBQAzdcgLFCHUtpwd86+B4mA==", + concStamp: "c11d44ef-29c5-43ac-89f9-a2e7cd482ec8", + secStamp: "MHG763AUJVOJPKKWUC64FQSF7DIVVBOU" + ), CreateAppUser( id: Constants.Users.AdminId, accountId: Shared.Domain.DomainConstants.Users.AdminAccountId, diff --git a/src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs b/src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs new file mode 100644 index 000000000..8a48b8e9d --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs @@ -0,0 +1,483 @@ +// +using System; +using CustomCADs.Modules.Identity.Infrastructure.Identity.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CustomCADs.Modules.Identity.Infrastructure.Migrations +{ + [DbContext(typeof(IdentityContext))] + [Migration("20260424222719_Added_HeadDesigner_User")] + partial class Added_HeadDesigner_User + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Identity") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ExpiresAt"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("IssuedAt"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("UserId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Value"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AppRefreshToken", "Identity"); + }); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "Identity"); + + b.HasData( + new + { + Id = new Guid("762ddec2-25c9-4183-9891-72a19d84a839"), + ConcurrencyStamp = "51da1b9f-803c-4bd3-9a00-da7ac259ce32", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = new Guid("e1101e2c-32cc-456f-9c82-4f1d1a65d141"), + ConcurrencyStamp = "a1a170e0-ee84-4afe-afd9-1df57009f291", + Name = "Contributor", + NormalizedName = "CONTRIBUTOR" + }, + new + { + Id = new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733"), + ConcurrencyStamp = "1a8ba0a7-4853-42da-980d-3107784e7ab1", + Name = "Designer", + NormalizedName = "DESIGNER" + }, + new + { + Id = new Guid("fad1b19d-5333-4633-bd84-d67c64649f65"), + ConcurrencyStamp = "42174679-32f1-48b0-9524-0f00791ec760", + Name = "Administrator", + NormalizedName = "ADMINISTRATOR" + }); + }); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("AccountId"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsSSO") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("Provider") + .HasColumnType("text") + .HasColumnName("Provider"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "Identity"); + + b.HasData( + new + { + Id = new Guid("e38c495f-b1f3-4226-d289-08dd11623eb9"), + AccessFailedCount = 0, + AccountId = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"), + ConcurrencyStamp = "0c5bbfb2-d132-407b-9b1b-e1e640ccc14e", + Email = "ivanzlatinov006@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "IVANZLATINOV006@GMAIL.COM", + NormalizedUserName = "FOR7A7A", + PasswordHash = "AQAAAAIAAYagAAAAEJFCGOTxNAgjhqU5lrA63WEtv924ujxXHt0x1R70qlS8dV9Pzz4II8GOgjVOaRzuDQ==", + PhoneNumberConfirmed = false, + SecurityStamp = "3A6TFN6VVZNRZEG22J777XJTPQY7342B", + TwoFactorEnabled = false, + UserName = "For7a7a" + }, + new + { + Id = new Guid("af840410-f3f2-4a3b-d28a-08dd11623eb9"), + AccessFailedCount = 0, + AccountId = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"), + ConcurrencyStamp = "c77927de-61e7-4d53-be8d-a5390fafc75c", + Email = "PDMatsaliev20@codingburgas.bg", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "PDMATSALIEV20@CODINGBURGAS.BG", + NormalizedUserName = "PDMATSALIEV20", + PasswordHash = "AQAAAAIAAYagAAAAEGjQ1Zes3r2XJgjoHQykiyr11OgUEDw+YDnOKeENyN7Kqi9RWKKRCtwd7ZtEyywdYA==", + PhoneNumberConfirmed = false, + SecurityStamp = "NWGZ3JTQSDNS346DMU7RP4IT4BDLHIQC", + TwoFactorEnabled = false, + UserName = "PDMatsaliev20" + }, + new + { + Id = new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde"), + AccessFailedCount = 0, + AccountId = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), + ConcurrencyStamp = "c5940d6f-d5c0-4f84-a262-da9b07525c3c", + Email = "john.cad@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "JOHN.CAD@GMAIL.COM", + NormalizedUserName = "JOHN_CAD", + PasswordHash = "AQAAAAIAAYagAAAAEEUe31maWfuZY6V8MQBzUWKerMKobDukREinVfML3Yl2z+Nr6IIQZKvX4WKqbTUw6w==", + PhoneNumberConfirmed = false, + SecurityStamp = "FNNIT3NPOZKZK2E67WFLV5R3RGVBX7LV", + TwoFactorEnabled = false, + UserName = "John_CAD" + }, + new + { + Id = new Guid("4337a774-2c5c-4c27-d28b-08dd11623eb9"), + AccessFailedCount = 0, + AccountId = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + ConcurrencyStamp = "c11d44ef-29c5-43ac-89f9-a2e7cd482ec8", + Email = "boriskolev2006@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "BORISKOLEV2006@GMAIL.COM", + NormalizedUserName = "ORACLE3000", + PasswordHash = "AQAAAAIAAYagAAAAEJGRCbKUsSh9BwxPXoIQRG1AVXYDfmWsY5vEA4aEnqBBQAzdcgLFCHUtpwd86+B4mA==", + PhoneNumberConfirmed = false, + SecurityStamp = "MHG763AUJVOJPKKWUC64FQSF7DIVVBOU", + TwoFactorEnabled = false, + UserName = "Oracle3000" + }, + new + { + Id = new Guid("cb7749fb-3fff-4902-d28c-08dd11623eb9"), + AccessFailedCount = 0, + AccountId = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"), + ConcurrencyStamp = "5c94b43f-861c-4efa-a670-5627e49d354d", + Email = "ivanangelov414@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "IVANANGELOV414@GMAIL.COM", + NormalizedUserName = "NINJATABG", + PasswordHash = "AQAAAAIAAYagAAAAEFqtQ33BvarNRyFcmV4z48fPBlIY8zd0de90qq3Cdm1Row+2WRmEjVJk1yPadBkrSA==", + PhoneNumberConfirmed = false, + SecurityStamp = "YIA26UZDSN2V2U5PVDEK4F3EJS3P5D3X", + TwoFactorEnabled = false, + UserName = "NinjataBG" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "Identity"); + + b.HasData( + new + { + UserId = new Guid("e38c495f-b1f3-4226-d289-08dd11623eb9"), + RoleId = new Guid("762ddec2-25c9-4183-9891-72a19d84a839") + }, + new + { + UserId = new Guid("af840410-f3f2-4a3b-d28a-08dd11623eb9"), + RoleId = new Guid("e1101e2c-32cc-456f-9c82-4f1d1a65d141") + }, + new + { + UserId = new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde"), + RoleId = new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733") + }, + new + { + UserId = new Guid("4337a774-2c5c-4c27-d28b-08dd11623eb9"), + RoleId = new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733") + }, + new + { + UserId = new Guid("cb7749fb-3fff-4902-d28c-08dd11623eb9"), + RoleId = new Guid("fad1b19d-5333-4633-bd84-d67c64649f65") + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "Identity"); + }); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRefreshToken", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.cs b/src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.cs new file mode 100644 index 000000000..fc206345b --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CustomCADs.Modules.Identity.Infrastructure.Migrations; + +/// +public partial class Added_HeadDesigner_User : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: "Identity", + table: "AspNetUsers", + keyColumn: "Id", + keyValue: new Guid("4337a774-2c5c-4c27-d28b-08dd11623eb9"), + columns: new[] { "ConcurrencyStamp", "PasswordHash", "SecurityStamp" }, + values: new object[] { "c11d44ef-29c5-43ac-89f9-a2e7cd482ec8", "AQAAAAIAAYagAAAAEJGRCbKUsSh9BwxPXoIQRG1AVXYDfmWsY5vEA4aEnqBBQAzdcgLFCHUtpwd86+B4mA==", "MHG763AUJVOJPKKWUC64FQSF7DIVVBOU" }); + + migrationBuilder.InsertData( + schema: "Identity", + table: "AspNetUsers", + columns: new[] { "Id", "AccessFailedCount", "AccountId", "ConcurrencyStamp", "Email", "EmailConfirmed", "IsSSO", "LockoutEnabled", "LockoutEnd", "NormalizedEmail", "NormalizedUserName", "PasswordHash", "PhoneNumber", "PhoneNumberConfirmed", "Provider", "SecurityStamp", "TwoFactorEnabled", "UserName" }, + values: new object[] { new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde"), 0, new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), "c5940d6f-d5c0-4f84-a262-da9b07525c3c", "john.cad@gmail.com", true, false, true, null, "JOHN.CAD@GMAIL.COM", "JOHN_CAD", "AQAAAAIAAYagAAAAEEUe31maWfuZY6V8MQBzUWKerMKobDukREinVfML3Yl2z+Nr6IIQZKvX4WKqbTUw6w==", null, false, null, "FNNIT3NPOZKZK2E67WFLV5R3RGVBX7LV", false, "John_CAD" }); + + migrationBuilder.InsertData( + schema: "Identity", + table: "AspNetUserRoles", + columns: new[] { "RoleId", "UserId" }, + values: new object[] { new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733"), new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde") }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + schema: "Identity", + table: "AspNetUserRoles", + keyColumns: new[] { "RoleId", "UserId" }, + keyValues: new object[] { new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733"), new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde") }); + + migrationBuilder.DeleteData( + schema: "Identity", + table: "AspNetUsers", + keyColumn: "Id", + keyValue: new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde")); + + migrationBuilder.UpdateData( + schema: "Identity", + table: "AspNetUsers", + keyColumn: "Id", + keyValue: new Guid("4337a774-2c5c-4c27-d28b-08dd11623eb9"), + columns: new[] { "ConcurrencyStamp", "PasswordHash", "SecurityStamp" }, + values: new object[] { "c5940d6f-d5c0-4f84-a262-da9b07525c3c", "AQAAAAIAAYagAAAAEEUe31maWfuZY6V8MQBzUWKerMKobDukREinVfML3Yl2z+Nr6IIQZKvX4WKqbTUw6w==", "FNNIT3NPOZKZK2E67WFLV5R3RGVBX7LV" }); + } +} diff --git a/src/Modules/Identity/Infrastructure/Migrations/IdentityContextModelSnapshot.cs b/src/Modules/Identity/Infrastructure/Migrations/IdentityContextModelSnapshot.cs index 0f82e2b95..53b045dec 100644 --- a/src/Modules/Identity/Infrastructure/Migrations/IdentityContextModelSnapshot.cs +++ b/src/Modules/Identity/Infrastructure/Migrations/IdentityContextModelSnapshot.cs @@ -223,20 +223,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) UserName = "PDMatsaliev20" }, new + { + Id = new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde"), + AccessFailedCount = 0, + AccountId = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), + ConcurrencyStamp = "c5940d6f-d5c0-4f84-a262-da9b07525c3c", + Email = "john.cad@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "JOHN.CAD@GMAIL.COM", + NormalizedUserName = "JOHN_CAD", + PasswordHash = "AQAAAAIAAYagAAAAEEUe31maWfuZY6V8MQBzUWKerMKobDukREinVfML3Yl2z+Nr6IIQZKvX4WKqbTUw6w==", + PhoneNumberConfirmed = false, + SecurityStamp = "FNNIT3NPOZKZK2E67WFLV5R3RGVBX7LV", + TwoFactorEnabled = false, + UserName = "John_CAD" + }, + new { Id = new Guid("4337a774-2c5c-4c27-d28b-08dd11623eb9"), AccessFailedCount = 0, AccountId = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), - ConcurrencyStamp = "c5940d6f-d5c0-4f84-a262-da9b07525c3c", + ConcurrencyStamp = "c11d44ef-29c5-43ac-89f9-a2e7cd482ec8", Email = "boriskolev2006@gmail.com", EmailConfirmed = true, IsSSO = false, LockoutEnabled = true, NormalizedEmail = "BORISKOLEV2006@GMAIL.COM", NormalizedUserName = "ORACLE3000", - PasswordHash = "AQAAAAIAAYagAAAAEEUe31maWfuZY6V8MQBzUWKerMKobDukREinVfML3Yl2z+Nr6IIQZKvX4WKqbTUw6w==", + PasswordHash = "AQAAAAIAAYagAAAAEJGRCbKUsSh9BwxPXoIQRG1AVXYDfmWsY5vEA4aEnqBBQAzdcgLFCHUtpwd86+B4mA==", PhoneNumberConfirmed = false, - SecurityStamp = "FNNIT3NPOZKZK2E67WFLV5R3RGVBX7LV", + SecurityStamp = "MHG763AUJVOJPKKWUC64FQSF7DIVVBOU", TwoFactorEnabled = false, UserName = "Oracle3000" }, @@ -355,6 +373,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) RoleId = new Guid("e1101e2c-32cc-456f-9c82-4f1d1a65d141") }, new + { + UserId = new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde"), + RoleId = new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733") + }, + new { UserId = new Guid("4337a774-2c5c-4c27-d28b-08dd11623eb9"), RoleId = new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733") diff --git a/src/Shared/Domain/DomainConstants.cs b/src/Shared/Domain/DomainConstants.cs index c81f264fc..8d69ef0cc 100644 --- a/src/Shared/Domain/DomainConstants.cs +++ b/src/Shared/Domain/DomainConstants.cs @@ -45,7 +45,8 @@ public static class Users { public static readonly AccountId CustomerAccountId = AccountId.New("2da61b05-1a27-4af9-9df2-be4f1f4e835f"); public static readonly AccountId ContributorAccountId = AccountId.New("6d963818-23dc-4e9a-aaa8-b4c77252bc97"); - public static readonly AccountId DesignerAccountId = AccountId.New("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"); + public static readonly AccountId DesignerAccountId = AccountId.New("8d477999-0580-4770-8864-e9ba4bed9cd1"); + public static readonly AccountId HeadDesignerAccountId = AccountId.New("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"); public static readonly AccountId AdminAccountId = AccountId.New("e995039c-a535-4f20-8288-7aadcb71b252"); public static readonly string[] Roles = [CustomerRole, ContributorRole, DesignerRole, AdminRole]; @@ -56,12 +57,14 @@ public static class Users public const string CustomerUsername = "For7a7a"; public const string ContributorUsername = "PDMatsaliev20"; - public const string DesignerUsername = "Oracle3000"; + public const string DesignerUsername = "John_CAD"; + public const string HeadDesignerUsername = "Oracle3000"; public const string AdminUsername = "NinjataBG"; public const string CustomerEmail = "ivanzlatinov006@gmail.com"; public const string ContributorEmail = "PDMatsaliev20@codingburgas.bg"; - public const string DesignerEmail = "boriskolev2006@gmail.com"; + public const string DesignerEmail = "john.cad@gmail.com"; + public const string HeadDesignerEmail = "boriskolev2006@gmail.com"; public const string AdminEmail = "ivanangelov414@gmail.com"; } } diff --git a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetUsernames/GetUsernamesByIdsHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetUsernames/GetUsernamesByIdsHandlerUnitTests.cs index ba6588039..f94f9cb7e 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetUsernames/GetUsernamesByIdsHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetUsernames/GetUsernamesByIdsHandlerUnitTests.cs @@ -15,7 +15,7 @@ public class GetUsernamesByIdsHandlerUnitTests : AccountsBaseUnitTests private readonly Mock reads = new(); private static readonly AccountId[] ids = [ValidId, ValidId, ValidId, ValidId]; - private static readonly string[] usernames = [CustomerUsername, ContributorUsername, DesignerUsername, AdminUsername]; + private static readonly string[] usernames = [CustomerUsername, ContributorUsername, DesignerUsername, HeadDesignerUsername, AdminUsername]; private static readonly AccountQuery accountQuery = new(Pagination: new(1, ids.Length), Ids: ids); public GetUsernamesByIdsHandlerUnitTests() @@ -28,6 +28,7 @@ public GetUsernamesByIdsHandlerUnitTests() CreateAccountWithId(id: AccountId.New(), username: CustomerUsername), CreateAccountWithId(id: AccountId.New(), username: ContributorUsername), CreateAccountWithId(id: AccountId.New(), username: DesignerUsername), + CreateAccountWithId(id: AccountId.New(), username: HeadDesignerUsername), CreateAccountWithId(id: AccountId.New(), username: AdminUsername), ] )); From 5261dec5f31e748a15b5821748962347c3c1d4bb Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 25 Apr 2026 01:34:19 +0300 Subject: [PATCH 07/22] Forbid SSO users from resetting their password --- .../Application/Contracts/IUserService.cs | 1 + .../ResetPasswordEmailValidator.cs | 16 ++++++++++++++++ .../Infrastructure/Identity/AppUserService.cs | 5 +++++ 3 files changed, 22 insertions(+) create mode 100644 src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs diff --git a/src/Modules/Identity/Application/Contracts/IUserService.cs b/src/Modules/Identity/Application/Contracts/IUserService.cs index b9a7d7870..091a870f1 100644 --- a/src/Modules/Identity/Application/Contracts/IUserService.cs +++ b/src/Modules/Identity/Application/Contracts/IUserService.cs @@ -14,6 +14,7 @@ public interface IUserService #region GetX Task GetExistsByUsernameAsync(string username); Task GetExistsByEmailAsync(string email); + Task GetIsSSOByEmailAsync(string email); Task GetAccountIdAsync(string username); Task GetIsLockedOutAsync(string username); #endregion diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs new file mode 100644 index 000000000..5e5eae742 --- /dev/null +++ b/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs @@ -0,0 +1,16 @@ +using CustomCADs.Shared.Application.Abstractions.Requests.Validator; +using FluentValidation; + +namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ResetPasswordEmail; + +public sealed class ResetPasswordEmailValidator : CommandValidator +{ + public ResetPasswordEmailValidator(IUserService service) + { + RuleFor(x => x.Email) + .MustAsync( + async (email, ct) => !await service.GetIsSSOByEmailAsync(email).ConfigureAwait(false) + ) + .WithMessage("Accounts with SSO have no Password to Reset."); + } +} diff --git a/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs b/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs index 4e84c15f3..ee623fda1 100644 --- a/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs +++ b/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs @@ -61,6 +61,11 @@ public async Task GetExistsByEmailAsync(string email) .AnyAsync(x => x.Email == email) .ConfigureAwait(false); + public async Task GetIsSSOByEmailAsync(string email) + => await manager.Users + .AnyAsync(x => x.Email == email && x.IsSSO) + .ConfigureAwait(false); + public async Task GetAccountIdAsync(string username) { AccountId accountId = await manager.Users From eff067a05cdb553a6df44313b3dbac71519ae6f1 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 25 Apr 2026 03:24:31 +0300 Subject: [PATCH 08/22] Improved file structure in Identity Infrastructure --- .../Infrastructure/DependencyInjection.cs | 4 +- .../Infrastructure/Identity/Extensions.cs | 24 ---- ...251215233938_Initial_Migration.Designer.cs | 0 .../20251215233938_Initial_Migration.cs | 0 ...222719_Added_HeadDesigner_User.Designer.cs | 0 .../20260424222719_Added_HeadDesigner_User.cs | 0 .../IdentityContextModelSnapshot.cs | 0 .../Identity/{ => Services}/AppRoleService.cs | 2 +- .../Identity/Services/AppUserService.Reads.cs | 103 +++++++++++++++++ .../AppUserService.Writes.cs} | 106 ++---------------- .../Identity/Services/AppUserService.cs | 10 ++ .../ShadowEntities/AppRefreshToken.cs | 4 +- .../Identity/ShadowEntities/AppUser.cs | 2 +- ...tityTokenService.cs => JwtTokenService.cs} | 2 +- 14 files changed, 129 insertions(+), 128 deletions(-) delete mode 100644 src/Modules/Identity/Infrastructure/Identity/Extensions.cs rename src/Modules/Identity/Infrastructure/{ => Identity}/Migrations/20251215233938_Initial_Migration.Designer.cs (100%) rename src/Modules/Identity/Infrastructure/{ => Identity}/Migrations/20251215233938_Initial_Migration.cs (100%) rename src/Modules/Identity/Infrastructure/{ => Identity}/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs (100%) rename src/Modules/Identity/Infrastructure/{ => Identity}/Migrations/20260424222719_Added_HeadDesigner_User.cs (100%) rename src/Modules/Identity/Infrastructure/{ => Identity}/Migrations/IdentityContextModelSnapshot.cs (100%) rename src/Modules/Identity/Infrastructure/Identity/{ => Services}/AppRoleService.cs (89%) create mode 100644 src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs rename src/Modules/Identity/Infrastructure/Identity/{AppUserService.cs => Services/AppUserService.Writes.cs} (57%) create mode 100644 src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.cs rename src/Modules/Identity/Infrastructure/Tokens/{IdentityTokenService.cs => JwtTokenService.cs} (96%) diff --git a/src/Modules/Identity/Infrastructure/DependencyInjection.cs b/src/Modules/Identity/Infrastructure/DependencyInjection.cs index 0039dfd39..f5396509b 100644 --- a/src/Modules/Identity/Infrastructure/DependencyInjection.cs +++ b/src/Modules/Identity/Infrastructure/DependencyInjection.cs @@ -1,8 +1,8 @@ #pragma warning disable IDE0130 using CustomCADs.Modules.Identity.Application.Contracts; using CustomCADs.Modules.Identity.Infrastructure.BackgroundJobs; -using CustomCADs.Modules.Identity.Infrastructure.Identity; using CustomCADs.Modules.Identity.Infrastructure.Identity.Context; +using CustomCADs.Modules.Identity.Infrastructure.Identity.Services; using CustomCADs.Modules.Identity.Infrastructure.Tokens; using Microsoft.EntityFrameworkCore; using Npgsql; @@ -26,7 +26,7 @@ public async Task UpdateIdentityContextAsync() extension(IServiceCollection services) { public IServiceCollection AddTokensService() - => services.AddScoped(); + => services.AddScoped(); public IServiceCollection AddIdentityServices(string connectionString) => services diff --git a/src/Modules/Identity/Infrastructure/Identity/Extensions.cs b/src/Modules/Identity/Infrastructure/Identity/Extensions.cs deleted file mode 100644 index d3970f710..000000000 --- a/src/Modules/Identity/Infrastructure/Identity/Extensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -using CustomCADs.Modules.Identity.Domain.Users; -using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; -using Microsoft.AspNetCore.Identity; - -namespace CustomCADs.Modules.Identity.Infrastructure.Identity; - -public static class Extensions -{ - extension(AppUser appUser) - { - public async Task ToUserWithRoleAsync(UserManager manager) - => appUser.ToUser(role: await manager.GetRoleAsync(appUser).ConfigureAwait(false)); - } - - extension(UserManager manager) - { - internal async Task GetRoleAsync(AppUser appUser) - { - var roles = await manager.GetRolesAsync(appUser).ConfigureAwait(false); - return roles.Single(); - } - } - -} diff --git a/src/Modules/Identity/Infrastructure/Migrations/20251215233938_Initial_Migration.Designer.cs b/src/Modules/Identity/Infrastructure/Identity/Migrations/20251215233938_Initial_Migration.Designer.cs similarity index 100% rename from src/Modules/Identity/Infrastructure/Migrations/20251215233938_Initial_Migration.Designer.cs rename to src/Modules/Identity/Infrastructure/Identity/Migrations/20251215233938_Initial_Migration.Designer.cs diff --git a/src/Modules/Identity/Infrastructure/Migrations/20251215233938_Initial_Migration.cs b/src/Modules/Identity/Infrastructure/Identity/Migrations/20251215233938_Initial_Migration.cs similarity index 100% rename from src/Modules/Identity/Infrastructure/Migrations/20251215233938_Initial_Migration.cs rename to src/Modules/Identity/Infrastructure/Identity/Migrations/20251215233938_Initial_Migration.cs diff --git a/src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs b/src/Modules/Identity/Infrastructure/Identity/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs similarity index 100% rename from src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs rename to src/Modules/Identity/Infrastructure/Identity/Migrations/20260424222719_Added_HeadDesigner_User.Designer.cs diff --git a/src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.cs b/src/Modules/Identity/Infrastructure/Identity/Migrations/20260424222719_Added_HeadDesigner_User.cs similarity index 100% rename from src/Modules/Identity/Infrastructure/Migrations/20260424222719_Added_HeadDesigner_User.cs rename to src/Modules/Identity/Infrastructure/Identity/Migrations/20260424222719_Added_HeadDesigner_User.cs diff --git a/src/Modules/Identity/Infrastructure/Migrations/IdentityContextModelSnapshot.cs b/src/Modules/Identity/Infrastructure/Identity/Migrations/IdentityContextModelSnapshot.cs similarity index 100% rename from src/Modules/Identity/Infrastructure/Migrations/IdentityContextModelSnapshot.cs rename to src/Modules/Identity/Infrastructure/Identity/Migrations/IdentityContextModelSnapshot.cs diff --git a/src/Modules/Identity/Infrastructure/Identity/AppRoleService.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppRoleService.cs similarity index 89% rename from src/Modules/Identity/Infrastructure/Identity/AppRoleService.cs rename to src/Modules/Identity/Infrastructure/Identity/Services/AppRoleService.cs index 76cd75723..d32f6e59d 100644 --- a/src/Modules/Identity/Infrastructure/Identity/AppRoleService.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppRoleService.cs @@ -2,7 +2,7 @@ using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; using Microsoft.AspNetCore.Identity; -namespace CustomCADs.Modules.Identity.Infrastructure.Identity; +namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Services; public class AppRoleService(RoleManager manager) : IRoleService { diff --git a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs new file mode 100644 index 000000000..2b01c4f80 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs @@ -0,0 +1,103 @@ +using CustomCADs.Modules.Identity.Domain.Users; +using CustomCADs.Modules.Identity.Domain.Users.Entities; +using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; +using CustomCADs.Shared.Application.Exceptions; +using CustomCADs.Shared.Domain.TypedIds.Accounts; +using Microsoft.EntityFrameworkCore; + +namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Services; + +public partial class AppUserService +{ + private async Task MapToUserAsync(AppUser user) + => user.ToUser( + role: (await manager.GetRolesAsync(user).ConfigureAwait(false)).Single() + ); + + #region GetUserByX + public async Task GetByUsernameAsync(string username) + { + AppUser appUser = await context.Users + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) + .ConfigureAwait(false) + ?? throw CustomNotFoundException.ByProp(nameof(username), username); + + return await MapToUserAsync(appUser).ConfigureAwait(false); + } + + public async Task GetByEmailAsync(string email) + { + AppUser appUser = await context.Users + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.Email == email) + .ConfigureAwait(false) + ?? throw CustomNotFoundException.ByProp(nameof(email), email); + + return await MapToUserAsync(appUser).ConfigureAwait(false); + } + + public async Task<(User User, RefreshToken RefreshToken)> GetByRefreshTokenAsync(string token) + { + AppUser appUser = await context.Users + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.RefreshTokens.Any(x => x.Value == token)) + .ConfigureAwait(false) + ?? throw CustomNotFoundException.ByProp(nameof(token), token); + + return ( + User: await MapToUserAsync(appUser).ConfigureAwait(false), + RefreshToken: appUser.RefreshTokens.First(x => x.Value == token).ToRefreshToken() + ); + } + #endregion + + #region GetPropertyX + public async Task GetExistsByUsernameAsync(string username) + => await context.Users + .AnyAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) + .ConfigureAwait(false); + + public async Task GetExistsByEmailAsync(string email) + => await context.Users + .AnyAsync(x => x.Email == email) + .ConfigureAwait(false); + + public async Task GetIsSSOByEmailAsync(string email) + => await context.Users + .AnyAsync(x => x.Email == email && x.IsSSO) + .ConfigureAwait(false); + + public async Task GetAccountIdAsync(string username) + { + AccountId accountId = await context.Users + .Where(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) + .Select(x => x.AccountId) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + + if (accountId.IsEmpty()) + { + throw CustomNotFoundException.ByProp(nameof(username), username); + } + + return accountId; + } + + public async Task GetIsLockedOutAsync(string username) + { + AppUser appUser = await context.Users + .FirstOrDefaultAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) + .ConfigureAwait(false) + ?? throw CustomNotFoundException.ByProp(nameof(username), username); + + bool isLockedOut = await manager.IsLockedOutAsync(appUser).ConfigureAwait(false); + if (!isLockedOut) + { + return null; + } + + return appUser.LockoutEnd; + } + #endregion +} diff --git a/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs similarity index 57% rename from src/Modules/Identity/Infrastructure/Identity/AppUserService.cs rename to src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs index ee623fda1..eea3c0101 100644 --- a/src/Modules/Identity/Infrastructure/Identity/AppUserService.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs @@ -1,104 +1,15 @@ -using CustomCADs.Modules.Identity.Application.Contracts; using CustomCADs.Modules.Identity.Domain.Users; using CustomCADs.Modules.Identity.Domain.Users.Entities; using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; using CustomCADs.Shared.Application.Exceptions; -using CustomCADs.Shared.Domain.TypedIds.Accounts; using CustomCADs.Shared.Domain.TypedIds.Identity; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -namespace CustomCADs.Modules.Identity.Infrastructure.Identity; +namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Services; -public class AppUserService(UserManager manager) : IUserService +public partial class AppUserService { - #region GetUserByX - public async Task GetByUsernameAsync(string username) - { - AppUser appUser = await manager.Users - .Include(x => x.RefreshTokens) - .FirstOrDefaultAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) - .ConfigureAwait(false) - ?? throw CustomNotFoundException.ByProp(nameof(username), username); - - return await appUser.ToUserWithRoleAsync(manager).ConfigureAwait(false); - } - - public async Task GetByEmailAsync(string email) - { - AppUser appUser = await manager.Users - .Include(x => x.RefreshTokens) - .FirstOrDefaultAsync(x => x.Email == email) - .ConfigureAwait(false) - ?? throw CustomNotFoundException.ByProp(nameof(email), email); - - return await appUser.ToUserWithRoleAsync(manager).ConfigureAwait(false); - } - - public async Task<(User User, RefreshToken RefreshToken)> GetByRefreshTokenAsync(string token) - { - AppUser appUser = await manager.Users - .Include(x => x.RefreshTokens) - .FirstOrDefaultAsync(x => x.RefreshTokens.Any(x => x.Value == token)) - .ConfigureAwait(false) - ?? throw CustomNotFoundException.ByProp(nameof(token), token); - - return ( - User: await appUser.ToUserWithRoleAsync(manager).ConfigureAwait(false), - RefreshToken: appUser.RefreshTokens.First(x => x.Value == token).ToRefreshToken() - ); - } - #endregion - - #region GetPropertyX - public async Task GetExistsByUsernameAsync(string username) - => await manager.Users - .AnyAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) - .ConfigureAwait(false); - - public async Task GetExistsByEmailAsync(string email) - => await manager.Users - .AnyAsync(x => x.Email == email) - .ConfigureAwait(false); - - public async Task GetIsSSOByEmailAsync(string email) - => await manager.Users - .AnyAsync(x => x.Email == email && x.IsSSO) - .ConfigureAwait(false); - - public async Task GetAccountIdAsync(string username) - { - AccountId accountId = await manager.Users - .Where(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) - .Select(x => x.AccountId) - .FirstOrDefaultAsync() - .ConfigureAwait(false); - - if (accountId.IsEmpty()) - { - throw CustomNotFoundException.ByProp(nameof(username), username); - } - - return accountId; - } - - public async Task GetIsLockedOutAsync(string username) - { - AppUser appUser = await manager.Users - .FirstOrDefaultAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) - .ConfigureAwait(false) - ?? throw CustomNotFoundException.ByProp(nameof(username), username); - - bool isLockedOut = await manager.IsLockedOutAsync(appUser).ConfigureAwait(false); - if (!isLockedOut) - { - return null; - } - - return appUser.LockoutEnd; - } - #endregion - #region Lifecycle public async Task CreateAsync(User user, string password) { @@ -126,7 +37,7 @@ public async Task CreateSSOAsync(User user, string provider) public async Task DeleteAsync(string username) { - AppUser appUser = await manager.FindByIdAsync(username).ConfigureAwait(false) + AppUser appUser = await manager.FindByNameAsync(username).ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(username), username); await manager.DeleteAsync(appUser).ConfigureAwait(false); @@ -163,11 +74,14 @@ public async Task UpdateUsernameAsync(UserId id, string username) public async Task SaveRefreshTokensAsync(User user) { - AppUser appUser = await manager.FindByIdAsync(user.Id.ToString()).ConfigureAwait(false) + AppUser appUser = await context.Users + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.Id == user.Id.Value) + .ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(user.Id), user.Id); appUser.FillRefreshTokens([.. user.RefreshTokens.Select(x => x.ToAppRefreshToken())]); - await manager.UpdateAsync(appUser).ConfigureAwait(false); + await context.SaveChangesAsync().ConfigureAwait(false); } public async Task RevokeRefreshTokenAsync(string token) @@ -175,11 +89,11 @@ public async Task RevokeRefreshTokenAsync(string token) (User User, RefreshToken RefreshToken) = await GetByRefreshTokenAsync(token).ConfigureAwait(false); User.RemoveRefreshToken(RefreshToken); - AppUser appUser = await manager.FindByIdAsync(User.Id.ToString()).ConfigureAwait(false) + AppUser appUser = await context.Users.FirstOrDefaultAsync(x => x.Id == User.Id.Value).ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(User.Id), User.Id); appUser.FillRefreshTokens([.. User.RefreshTokens.Select(x => x.ToAppRefreshToken())]); - await manager.UpdateAsync(appUser).ConfigureAwait(false); + await context.SaveChangesAsync().ConfigureAwait(false); } #endregion diff --git a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.cs new file mode 100644 index 000000000..cf1dfe74b --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.cs @@ -0,0 +1,10 @@ +using CustomCADs.Modules.Identity.Application.Contracts; +using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; +using Microsoft.AspNetCore.Identity; + +namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Services; + +public partial class AppUserService( + UserManager manager, + Context.IdentityContext context +) : IUserService; diff --git a/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppRefreshToken.cs b/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppRefreshToken.cs index e161e4990..a1295aa8a 100644 --- a/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppRefreshToken.cs +++ b/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppRefreshToken.cs @@ -1,8 +1,6 @@ -using CustomCADs.Shared.Domain.Bases.Entities; - namespace CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; -public class AppRefreshToken : BaseEntity +public class AppRefreshToken { public AppRefreshToken() { } public AppRefreshToken(string value, Guid userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt) diff --git a/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppUser.cs b/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppUser.cs index 90b082882..70002927b 100644 --- a/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppUser.cs +++ b/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppUser.cs @@ -5,7 +5,7 @@ namespace CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; public class AppUser : IdentityUser { - private List refreshTokens = []; + private readonly List refreshTokens = []; private string? provider; public AppUser() : base() { } diff --git a/src/Modules/Identity/Infrastructure/Tokens/IdentityTokenService.cs b/src/Modules/Identity/Infrastructure/Tokens/JwtTokenService.cs similarity index 96% rename from src/Modules/Identity/Infrastructure/Tokens/IdentityTokenService.cs rename to src/Modules/Identity/Infrastructure/Tokens/JwtTokenService.cs index c749d2d5e..a4ace05b6 100644 --- a/src/Modules/Identity/Infrastructure/Tokens/IdentityTokenService.cs +++ b/src/Modules/Identity/Infrastructure/Tokens/JwtTokenService.cs @@ -14,7 +14,7 @@ namespace CustomCADs.Modules.Identity.Infrastructure.Tokens; using static Domain.Constants.Tokens; -public sealed class IdentityTokenService(IOptions jwtOptions) : ITokenService +public sealed class JwtTokenService(IOptions jwtOptions) : ITokenService { private const string Algorithm = SecurityAlgorithms.HmacSha256; private readonly JwtSettings jwtSettings = jwtOptions.Value; From bc1a69b0b67901ec1a73a42c6da9ecc36a2b112e Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 25 Apr 2026 04:02:28 +0300 Subject: [PATCH 09/22] Improved JWT Events a tiny bit --- .../Identity/API/DependencyInjection.cs | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/Modules/Identity/API/DependencyInjection.cs b/src/Modules/Identity/API/DependencyInjection.cs index 7df22110b..aa4b4abd8 100644 --- a/src/Modules/Identity/API/DependencyInjection.cs +++ b/src/Modules/Identity/API/DependencyInjection.cs @@ -60,26 +60,16 @@ await context.HttpContext.RequestServices .GetRequiredService() .UnauthorizedResponseAsync( context: context.HttpContext, - ex: new UnauthorizedAccessException() + ex: new UnauthorizedAccessException("User needs an account to access this resource!") ).ConfigureAwait(false); }, - OnForbidden = async context => - { - await context.HttpContext.RequestServices + OnForbidden = async context => await context.HttpContext.RequestServices .GetRequiredService() .ForbiddenResponseAsync( context: context.HttpContext, - ex: new AccessViolationException() - ).ConfigureAwait(false); - }, - - OnTokenValidated = context => - { - ClaimsIdentity claimsIdentity = new(context.Principal?.Claims ?? [], AuthScheme); - context.HttpContext.User = new ClaimsPrincipal(claimsIdentity); - return Task.CompletedTask; - }, + ex: new UnauthorizedAccessException("User doesn't have permission to access this resource!") + ).ConfigureAwait(false), }; }); } From a58aff541b6df6be4ff3ba4d353cd64dfc089c67 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 25 Apr 2026 20:41:43 +0300 Subject: [PATCH 10/22] Implemented soft delete for Accounts --- .../Accounts/Domain/Accounts/Account.cs | 12 +- .../Configurations/Accounts/Configurations.cs | 3 +- .../Configurations/Accounts/Utilities.cs | 16 ++ ...mplemented_Accounts_SoftDelete.Designer.cs | 217 ++++++++++++++++++ ...5173905_Implemented_Accounts_SoftDelete.cs | 82 +++++++ .../AccountsContextModelSnapshot.cs | 13 ++ .../Repositories/Accounts/Writes.cs | 2 +- .../Domain/Bases/Entities/ISoftDeletable.cs | 8 + 8 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.Designer.cs create mode 100644 src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.cs create mode 100644 src/Shared/Domain/Bases/Entities/ISoftDeletable.cs diff --git a/src/Modules/Accounts/Domain/Accounts/Account.cs b/src/Modules/Accounts/Domain/Accounts/Account.cs index da5489c83..9ec8f1c58 100644 --- a/src/Modules/Accounts/Domain/Accounts/Account.cs +++ b/src/Modules/Accounts/Domain/Accounts/Account.cs @@ -2,7 +2,7 @@ namespace CustomCADs.Modules.Accounts.Domain.Accounts; -public class Account : BaseAggregateRoot +public class Account : BaseAggregateRoot, ISoftDeletable { private Account() { } private Account( @@ -30,6 +30,8 @@ private Account( public string RoleName { get; private set; } = string.Empty; public bool TrackViewedProducts { get; private set; } public DateTimeOffset CreatedAt { get; private set; } + public bool IsDeleted { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } public static Account Create( string role, @@ -96,4 +98,12 @@ public Account SetTrackViewedProducts(bool track) TrackViewedProducts = track; return this; } + + public Account Delete() + { + IsDeleted = true; + DeletedAt = DateTimeOffset.UtcNow; + + return this; + } } diff --git a/src/Modules/Accounts/Persistence/Configurations/Accounts/Configurations.cs b/src/Modules/Accounts/Persistence/Configurations/Accounts/Configurations.cs index 1dea47536..39ae3bc2c 100644 --- a/src/Modules/Accounts/Persistence/Configurations/Accounts/Configurations.cs +++ b/src/Modules/Accounts/Persistence/Configurations/Accounts/Configurations.cs @@ -12,6 +12,7 @@ public void Configure(EntityTypeBuilder builder) .SetStronglyTypedIds() .SetIndexes() .SetValidations() - .SetSeeding(); + .SetSeeding() + .SetFilters(); } } diff --git a/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs b/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs index a54d55cfe..2930ead32 100644 --- a/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs +++ b/src/Modules/Accounts/Persistence/Configurations/Accounts/Utilities.cs @@ -69,6 +69,15 @@ internal EntityTypeBuilder SetValidations() .IsRequired() .HasColumnName(nameof(Account.CreatedAt)); + builder + .Property(x => x.IsDeleted) + .IsRequired() + .HasColumnName(nameof(Account.IsDeleted)); + + builder + .Property(x => x.DeletedAt) + .HasColumnName(nameof(Account.DeletedAt)); + return builder; } @@ -84,5 +93,12 @@ internal EntityTypeBuilder SetSeeding() return builder; } + + internal EntityTypeBuilder SetFilters() + { + builder.HasQueryFilter(x => !x.IsDeleted); + + return builder; + } } } diff --git a/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.Designer.cs b/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.Designer.cs new file mode 100644 index 000000000..9b0665c16 --- /dev/null +++ b/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.Designer.cs @@ -0,0 +1,217 @@ +// +using System; +using CustomCADs.Modules.Accounts.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CustomCADs.Modules.Accounts.Persistence.Migrations +{ + [DbContext(typeof(AccountsContext))] + [Migration("20260425173905_Implemented_Accounts_SoftDelete")] + partial class Implemented_Accounts_SoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Accounts") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("DeletedAt"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Email"); + + b.Property("FirstName") + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("FirstName"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("IsDeleted"); + + b.Property("LastName") + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("LastName"); + + b.Property("RoleName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("RoleName"); + + b.Property("TrackViewedProducts") + .HasColumnType("boolean") + .HasColumnName("TrackViewedProducts"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("Username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Accounts", "Accounts"); + + b.HasData( + new + { + Id = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 10, 19, 23, 12, 123, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "ivanzlatinov006@gmail.com", + IsDeleted = false, + RoleName = "Customer", + TrackViewedProducts = true, + Username = "For7a7a" + }, + new + { + Id = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 13, 17, 42, 57, 456, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "PDMatsaliev20@codingburgas.bg", + IsDeleted = false, + RoleName = "Contributor", + TrackViewedProducts = true, + Username = "PDMatsaliev20" + }, + new + { + Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "john.cad@gmail.com", + IsDeleted = false, + RoleName = "Designer", + TrackViewedProducts = true, + Username = "John_CAD" + }, + new + { + Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "boriskolev2006@gmail.com", + IsDeleted = false, + RoleName = "Designer", + TrackViewedProducts = true, + Username = "Oracle3000" + }, + new + { + Id = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"), + CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 45, 13, 0, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "ivanangelov414@gmail.com", + IsDeleted = false, + RoleName = "Administrator", + TrackViewedProducts = true, + Username = "NinjataBG" + }); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("Description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Name"); + + b.HasKey("Id"); + + b.ToTable("Roles", "Accounts"); + + b.HasData( + new + { + Id = 1, + Description = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments.", + Name = "Customer" + }, + new + { + Id = 2, + Description = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself.", + Name = "Contributor" + }, + new + { + Id = 3, + Description = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do.", + Name = "Designer" + }, + new + { + Id = 4, + Description = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles.", + Name = "Administrator" + }); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.HasKey("AccountId", "ProductId"); + + b.ToTable("ViewedProducts", "Accounts"); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b => + { + b.HasOne("CustomCADs.Modules.Accounts.Domain.Accounts.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.cs b/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.cs new file mode 100644 index 000000000..d3d12358a --- /dev/null +++ b/src/Modules/Accounts/Persistence/Migrations/20260425173905_Implemented_Accounts_SoftDelete.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CustomCADs.Modules.Accounts.Persistence.Migrations; + +/// +public partial class Implemented_Accounts_SoftDelete : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + schema: "Accounts", + table: "Accounts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + schema: "Accounts", + table: "Accounts", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.UpdateData( + schema: "Accounts", + table: "Accounts", + keyColumn: "Id", + keyValue: new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + columns: new[] { "DeletedAt", "IsDeleted" }, + values: new object[] { null, false }); + + migrationBuilder.UpdateData( + schema: "Accounts", + table: "Accounts", + keyColumn: "Id", + keyValue: new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"), + columns: new[] { "DeletedAt", "IsDeleted" }, + values: new object[] { null, false }); + + migrationBuilder.UpdateData( + schema: "Accounts", + table: "Accounts", + keyColumn: "Id", + keyValue: new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"), + columns: new[] { "DeletedAt", "IsDeleted" }, + values: new object[] { null, false }); + + migrationBuilder.UpdateData( + schema: "Accounts", + table: "Accounts", + keyColumn: "Id", + keyValue: new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), + columns: new[] { "DeletedAt", "IsDeleted" }, + values: new object[] { null, false }); + + migrationBuilder.UpdateData( + schema: "Accounts", + table: "Accounts", + keyColumn: "Id", + keyValue: new Guid("e995039c-a535-4f20-8288-7aadcb71b252"), + columns: new[] { "DeletedAt", "IsDeleted" }, + values: new object[] { null, false }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + schema: "Accounts", + table: "Accounts"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + schema: "Accounts", + table: "Accounts"); + } +} diff --git a/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs b/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs index 695326ed5..974878234 100644 --- a/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs +++ b/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs @@ -33,6 +33,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone") .HasColumnName("CreatedAt"); + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("DeletedAt"); + b.Property("Email") .IsRequired() .HasColumnType("text") @@ -43,6 +47,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(62)") .HasColumnName("FirstName"); + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("IsDeleted"); + b.Property("LastName") .HasMaxLength(62) .HasColumnType("character varying(62)") @@ -79,6 +87,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) Id = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"), CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 10, 19, 23, 12, 123, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), Email = "ivanzlatinov006@gmail.com", + IsDeleted = false, RoleName = "Customer", TrackViewedProducts = true, Username = "For7a7a" @@ -88,6 +97,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) Id = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"), CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 13, 17, 42, 57, 456, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), Email = "PDMatsaliev20@codingburgas.bg", + IsDeleted = false, RoleName = "Contributor", TrackViewedProducts = true, Username = "PDMatsaliev20" @@ -97,6 +107,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), Email = "john.cad@gmail.com", + IsDeleted = false, RoleName = "Designer", TrackViewedProducts = true, Username = "John_CAD" @@ -106,6 +117,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), Email = "boriskolev2006@gmail.com", + IsDeleted = false, RoleName = "Designer", TrackViewedProducts = true, Username = "Oracle3000" @@ -115,6 +127,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) Id = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"), CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 45, 13, 0, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), Email = "ivanangelov414@gmail.com", + IsDeleted = false, RoleName = "Administrator", TrackViewedProducts = true, Username = "NinjataBG" diff --git a/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs b/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs index efd66590a..b4f400b42 100644 --- a/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs +++ b/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs @@ -17,5 +17,5 @@ public async Task ViewProductAsync(AccountId id, ProductId productId, Cancellati ).ConfigureAwait(false); public void Remove(Account entity) - => context.Accounts.Remove(entity); + => entity.Delete(); } diff --git a/src/Shared/Domain/Bases/Entities/ISoftDeletable.cs b/src/Shared/Domain/Bases/Entities/ISoftDeletable.cs new file mode 100644 index 000000000..5aa48c817 --- /dev/null +++ b/src/Shared/Domain/Bases/Entities/ISoftDeletable.cs @@ -0,0 +1,8 @@ +namespace CustomCADs.Shared.Domain.Bases.Entities; + +public interface ISoftDeletable where TEntity : BaseEntity +{ + bool IsDeleted { get; } + DateTimeOffset? DeletedAt { get; } + TEntity Delete(); +} From a45bd256424d0d4c6a5321439fe51ceb3c33156c Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Mon, 27 Apr 2026 20:16:19 +0300 Subject: [PATCH 11/22] Allowed First and Last name modification --- .../src/api/identity/identity/change-names.ts | 9 ++++ .../api/identity/identity/change-username.ts | 7 --- .../react/src/api/identity/identity/index.ts | 6 +-- .../react/src/api/identity/identity/types.ts | 2 +- .../src/hooks/mutations/identity/identity.ts | 8 ++-- .../Events/Application/UserEditedHandler.cs | 13 +++++ .../GetAccountExistsByUsernameHandler.cs | 11 +++++ .../Info/GetAccountInfoByUsernameHandler.cs | 1 + .../ProductViewed/ProductViewedHandler.cs | 4 +- .../Get/DownloadInfo/DownloadInfoEndpoint.cs | 2 +- .../Get/MyAccount/MyAccountEndpoint.cs | 2 +- .../ChangeUsername/ChangeUsernameRequest.cs | 5 -- .../ChangeNamesEndpoint.cs} | 16 ++++--- .../Patch/Names/ChangeUsernameRequest.cs | 7 +++ .../Application/Contracts/IUserService.cs | 1 + .../ChangeUsername/ChangeUsernameCommand.cs | 8 +++- .../ChangeUsername/ChangeUsernameHandler.cs | 7 +-- .../ChangeUsername/ChangeUsernameValidator.cs | 43 +++++++++++++++++ .../GetByUsername/GetUserByUsernameHandler.cs | 6 +-- .../GetByUsername/GetUserByUsernameQuery.cs | 3 +- .../Identity/Services/AppUserService.Reads.cs | 11 +++++ .../Identity/UserEditedApplicationEvent.cs | 3 ++ .../GetAccountExistsByUsernameQuery.cs | 3 ++ .../Queries/GetAccountInfoByUsernameQuery.cs | 1 + ...AccountExistsByUsernameHandlerUnitTests.cs | 47 +++++++++++++++++++ ...etAccountInfoByUsernameHandlerUnitTests.cs | 1 + .../ProductViewedHandlerUnitTests.cs | 1 + .../ChangeUsernameHandlerUnitTests.cs | 18 ++++--- ...eViewedProductsTrackingHandlerUnitTests.cs | 2 +- .../GetUserByUsernameHandlerUnitTests.cs | 15 +++--- 30 files changed, 208 insertions(+), 55 deletions(-) create mode 100644 sdk/js/react/src/api/identity/identity/change-names.ts delete mode 100644 sdk/js/react/src/api/identity/identity/change-username.ts create mode 100644 src/Modules/Accounts/Application/Accounts/Queries/Shared/Exists/GetAccountExistsByUsernameHandler.cs delete mode 100644 src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameRequest.cs rename src/Modules/Identity/API/Identity/Patch/{ChangeUsername/ChangeUsernameEndpoint.cs => Names/ChangeNamesEndpoint.cs} (51%) create mode 100644 src/Modules/Identity/API/Identity/Patch/Names/ChangeUsernameRequest.cs create mode 100644 src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameValidator.cs create mode 100644 src/Shared/Application/UseCases/Accounts/Queries/GetAccountExistsByUsernameQuery.cs create mode 100644 tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetExists/GetAccountExistsByUsernameHandlerUnitTests.cs diff --git a/sdk/js/react/src/api/identity/identity/change-names.ts b/sdk/js/react/src/api/identity/identity/change-names.ts new file mode 100644 index 000000000..17a4b525c --- /dev/null +++ b/sdk/js/react/src/api/identity/identity/change-names.ts @@ -0,0 +1,9 @@ +import { IDENTITY_BASE_PATH } from '../common'; + +export type Request = { + username: string; + firstName?: string; + lastName?: string; +}; + +export const url = () => `${IDENTITY_BASE_PATH}/names`; diff --git a/sdk/js/react/src/api/identity/identity/change-username.ts b/sdk/js/react/src/api/identity/identity/change-username.ts deleted file mode 100644 index 215c3708a..000000000 --- a/sdk/js/react/src/api/identity/identity/change-username.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IDENTITY_BASE_PATH } from '../common'; - -export type Request = { - username: string; -}; - -export const url = () => `${IDENTITY_BASE_PATH}/username`; diff --git a/sdk/js/react/src/api/identity/identity/index.ts b/sdk/js/react/src/api/identity/identity/index.ts index 3a982ae45..5dc95bbf4 100644 --- a/sdk/js/react/src/api/identity/identity/index.ts +++ b/sdk/js/react/src/api/identity/identity/index.ts @@ -7,7 +7,7 @@ import * as loginResources from './login'; import * as refreshResources from './refresh'; import * as logoutResources from './logout'; import * as deleteResources from './delete'; -import * as changeUsernameResources from './change-username'; +import * as changeNamesResources from './change-names'; import * as toggleTrackViewedProductsResources from './toggle-track-viewed-products'; import * as forgotPasswordResources from './forgot-password'; import * as resetPasswordResources from './reset-password'; @@ -37,8 +37,8 @@ export const refresh = async () => export const logout = async () => await axios.post(logoutResources.url()); -export const changeUsername = async (req: changeUsernameResources.Request) => - await axios.patch(changeUsernameResources.url(), req); +export const changeUsername = async (req: changeNamesResources.Request) => + await axios.patch(changeNamesResources.url(), req); export const toggleTrackViewedProducts = async () => await axios.patch(toggleTrackViewedProductsResources.url()); diff --git a/sdk/js/react/src/api/identity/identity/types.ts b/sdk/js/react/src/api/identity/identity/types.ts index b4194566f..495b8380c 100644 --- a/sdk/js/react/src/api/identity/identity/types.ts +++ b/sdk/js/react/src/api/identity/identity/types.ts @@ -1,6 +1,6 @@ export type { Response as AuthnResponse } from './authn'; export type { Response as AuthzResponse } from './authz'; -export type { Request as ChangeUsernameRequest } from './change-username'; +export type { Request as ChangeNamesRequest } from './change-names'; export type { Request as ConfirmEmailRequest } from './confirm-email'; export type { Response as DownloadUserInfoResponse } from './download-info'; export type { Request as ForgotPasswordRequest } from './forgot-password'; diff --git a/sdk/js/react/src/hooks/mutations/identity/identity.ts b/sdk/js/react/src/hooks/mutations/identity/identity.ts index 144226443..bd709fd00 100644 --- a/sdk/js/react/src/hooks/mutations/identity/identity.ts +++ b/sdk/js/react/src/hooks/mutations/identity/identity.ts @@ -3,7 +3,7 @@ import { identityApi as api } from '@/api'; import { Request as Login } from '@/api/identity/identity/login'; import { Request as ForgotPassword } from '@/api/identity/identity/forgot-password'; import { Request as ResetPassword } from '@/api/identity/identity/reset-password'; -import { Request as ChangeUsername } from '@/api/identity/identity/change-username'; +import { Request as ChangeNames } from '@/api/identity/identity/change-names'; import { Request as Register } from '@/api/identity/identity/register'; import { Request as ConfirmEmail } from '@/api/identity/identity/confirm-email'; import { Request as RetryConfirmEmail } from '@/api/identity/identity/retry-confirm-email'; @@ -22,9 +22,9 @@ export const identity = { mutationKey: [...BASE_KEY, 'refresh'], mutationFn: async () => (await api.refresh()).data, }), - changeUsername: mutationOptions({ - mutationKey: [...BASE_KEY, 'change-username'], - mutationFn: async (params: ChangeUsername) => + changeNames: mutationOptions({ + mutationKey: [...BASE_KEY, 'change-names'], + mutationFn: async (params: ChangeNames) => (await api.changeUsername(params)).data, }), toggleTrackViewedProducts: mutationOptions({ diff --git a/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs b/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs index 01a242781..bf25739cd 100644 --- a/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs +++ b/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs @@ -19,6 +19,19 @@ public async Task HandleAsync(UserEditedApplicationEvent ae) { account.SetTrackViewedProducts(ae.TrackViewedProducts.Value); } + + if (ae.Names is not null) + { + if (ae.Names.FirstName is not null) + { + account.SetFirstName(ae.Names.FirstName); + } + if (ae.Names.LastName is not null) + { + account.SetLastName(ae.Names.LastName); + } + } + await uow.SaveChangesAsync().ConfigureAwait(false); } } diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/Exists/GetAccountExistsByUsernameHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/Exists/GetAccountExistsByUsernameHandler.cs new file mode 100644 index 000000000..c1997c85b --- /dev/null +++ b/src/Modules/Accounts/Application/Accounts/Queries/Shared/Exists/GetAccountExistsByUsernameHandler.cs @@ -0,0 +1,11 @@ +using CustomCADs.Modules.Accounts.Domain.Repositories.Reads; +using CustomCADs.Shared.Application.UseCases.Accounts.Queries; + +namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.Exists; + +public sealed class GetAccountExistsByUsernameHandler(IAccountReads reads) + : IQueryHandler +{ + public async Task Handle(GetAccountExistsByUsernameQuery req, CancellationToken ct) + => await reads.ExistsByUsernameAsync(req.Username, ct).ConfigureAwait(false); +} diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/Info/GetAccountInfoByUsernameHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/Info/GetAccountInfoByUsernameHandler.cs index 9a6da1c4b..000f4aff9 100644 --- a/src/Modules/Accounts/Application/Accounts/Queries/Shared/Info/GetAccountInfoByUsernameHandler.cs +++ b/src/Modules/Accounts/Application/Accounts/Queries/Shared/Info/GetAccountInfoByUsernameHandler.cs @@ -12,6 +12,7 @@ public async Task Handle(GetAccountInfoByUsernameQuery req, Canc ?? throw CustomNotFoundException.ByProp(nameof(req.Username), req.Username); return new( + Id: account.Id, CreatedAt: account.CreatedAt, TrackViewedProducts: account.TrackViewedProducts, FirstName: account.FirstName, diff --git a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs index 4ac355d0f..3661c0373 100644 --- a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs +++ b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs @@ -18,11 +18,11 @@ public async Task HandleAsync(ProductViewedApplicationEvent ae) query: new GetUsernameByIdQuery(ae.AccountId) ).ConfigureAwait(false); - (DateTimeOffset _, bool UserTracksViewedProducts, string? _, string? _) = await sender.SendQueryAsync( + AccountInfoDto account = await sender.SendQueryAsync( query: new GetAccountInfoByUsernameQuery(username) ).ConfigureAwait(false); - if (!UserTracksViewedProducts) + if (!account.TrackViewedProducts) { return; } diff --git a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs index 083cabb8e..fb9ac3e6b 100644 --- a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs @@ -19,7 +19,7 @@ public override void Configure() public override async Task HandleAsync(CancellationToken ct) { GetUserByUsernameDto user = await sender.SendQueryAsync( - query: new GetUserByUsernameQuery(User.Name), + query: new GetUserByUsernameQuery(User.AccountId), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs index ae0f3cb87..3b6c94b7e 100644 --- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs @@ -18,7 +18,7 @@ public override void Configure() public override async Task HandleAsync(CancellationToken ct) { GetUserByUsernameDto user = await sender.SendQueryAsync( - query: new GetUserByUsernameQuery(User.Name), + query: new GetUserByUsernameQuery(User.AccountId), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameRequest.cs b/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameRequest.cs deleted file mode 100644 index 94d1c73ea..000000000 --- a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameRequest.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace CustomCADs.Modules.Identity.API.Identity.Patch.ChangeUsername; - -public sealed record ChangeUsernameRequest( - string Username -); diff --git a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs b/src/Modules/Identity/API/Identity/Patch/Names/ChangeNamesEndpoint.cs similarity index 51% rename from src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs rename to src/Modules/Identity/API/Identity/Patch/Names/ChangeNamesEndpoint.cs index c04cc4c48..42716b5f7 100644 --- a/src/Modules/Identity/API/Identity/Patch/ChangeUsername/ChangeUsernameEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Patch/Names/ChangeNamesEndpoint.cs @@ -1,13 +1,13 @@ using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ChangeUsername; -namespace CustomCADs.Modules.Identity.API.Identity.Patch.ChangeUsername; +namespace CustomCADs.Modules.Identity.API.Identity.Patch.Names; -public sealed class ChangeUsernameEndpoint(IRequestSender sender) - : Endpoint +public sealed class ChangeNamesEndpoint(IRequestSender sender) + : Endpoint { public override void Configure() { - Patch("username"); + Patch("names"); Group(); Description(x => x .WithSummary("Change Username") @@ -15,12 +15,14 @@ public override void Configure() ); } - public override async Task HandleAsync(ChangeUsernameRequest req, CancellationToken ct) + public override async Task HandleAsync(ChangeNamesRequest req, CancellationToken ct) { await sender.SendCommandAsync( command: new ChangeUsernameCommand( - Username: User.Name, - NewUsername: req.Username + Id: User.AccountId, + Username: req.Username, + FirstName: req.FirstName, + LastName: req.LastName ), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Patch/Names/ChangeUsernameRequest.cs b/src/Modules/Identity/API/Identity/Patch/Names/ChangeUsernameRequest.cs new file mode 100644 index 000000000..6187bd24e --- /dev/null +++ b/src/Modules/Identity/API/Identity/Patch/Names/ChangeUsernameRequest.cs @@ -0,0 +1,7 @@ +namespace CustomCADs.Modules.Identity.API.Identity.Patch.Names; + +public sealed record ChangeNamesRequest( + string Username, + string? FirstName, + string? LastName +); diff --git a/src/Modules/Identity/Application/Contracts/IUserService.cs b/src/Modules/Identity/Application/Contracts/IUserService.cs index 091a870f1..33db7979f 100644 --- a/src/Modules/Identity/Application/Contracts/IUserService.cs +++ b/src/Modules/Identity/Application/Contracts/IUserService.cs @@ -6,6 +6,7 @@ namespace CustomCADs.Modules.Identity.Application.Contracts; public interface IUserService { #region GetUserByX + Task GetByAccountIdAsync(AccountId accountId); Task GetByUsernameAsync(string username); Task GetByEmailAsync(string email); Task<(User User, RefreshToken RefreshToken)> GetByRefreshTokenAsync(string token); diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameCommand.cs index 28d272ef6..fbe56d7e5 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameCommand.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameCommand.cs @@ -1,6 +1,10 @@ -namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ChangeUsername; +using CustomCADs.Shared.Domain.TypedIds.Accounts; + +namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ChangeUsername; public sealed record ChangeUsernameCommand( + AccountId Id, string Username, - string NewUsername + string? FirstName, + string? LastName ) : ICommand; diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandler.cs index 3690a97fa..4962f973b 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandler.cs @@ -10,15 +10,16 @@ IEventRaiser raiser { public async Task Handle(ChangeUsernameCommand req, CancellationToken ct) { - User user = await service.GetByUsernameAsync(req.Username).ConfigureAwait(false); + User user = await service.GetByAccountIdAsync(req.Id).ConfigureAwait(false); - user.SetUsername(req.NewUsername); + user.SetUsername(req.Username); await service.UpdateUsernameAsync(user.Id, user.Username).ConfigureAwait(false); await raiser.RaiseApplicationEventAsync( @event: new UserEditedApplicationEvent( Id: user.AccountId, - Username: user.Username + Username: user.Username, + Names: new(req.FirstName, req.LastName) ) ).ConfigureAwait(false); } diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameValidator.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameValidator.cs new file mode 100644 index 000000000..17597c5b9 --- /dev/null +++ b/src/Modules/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameValidator.cs @@ -0,0 +1,43 @@ +using CustomCADs.Shared.Application.Abstractions.Requests.Sender; +using CustomCADs.Shared.Application.Abstractions.Requests.Validator; +using CustomCADs.Shared.Application.Exceptions; +using CustomCADs.Shared.Application.UseCases.Accounts.Queries; +using FluentValidation; + +namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ChangeUsername; + +public sealed class ChangeUsernameValidator : CommandValidator +{ + public ChangeUsernameValidator(IRequestSender sender) + { + RuleFor(x => x) + .MustAsync(async (command, ct) => + { + bool usernameIsDuplicate = await sender.SendQueryAsync( + query: new GetAccountExistsByUsernameQuery(command.Username), + ct: ct + ).ConfigureAwait(false); + + return !usernameIsDuplicate; + }) + .WhenAsync(async (command, ct) => + { + try + { + var info = await sender.SendQueryAsync( + query: new GetAccountInfoByUsernameQuery(command.Username), + ct: ct + ).ConfigureAwait(false); + + // Don't run if the account with this username is the user's own + return info.Id != command.Id; + } + catch (Exception ex) when (ex.GetType().IsGenericType && ex.GetType().GetGenericTypeDefinition() == typeof(CustomNotFoundException<>)) + { + // Don't run if the account with this username doesn't exist + return false; + } + }) + .WithMessage("Username already taken"); + } +} diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs index 121314789..41b5a2ff1 100644 --- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs +++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs @@ -10,15 +10,15 @@ public sealed class GetUserByUsernameHandler(IUserService service, IRequestSende { public async Task Handle(GetUserByUsernameQuery req, CancellationToken ct = default) { - User user = await service.GetByUsernameAsync(req.Username).ConfigureAwait(false); + User user = await service.GetByAccountIdAsync(req.Id).ConfigureAwait(false); AccountInfoDto info = await sender.SendQueryAsync( - query: new GetAccountInfoByUsernameQuery(req.Username), + query: new GetAccountInfoByUsernameQuery(user.Username), ct: ct ).ConfigureAwait(false); ProductId[] viewedProductIds = await sender.SendQueryAsync( - query: new GetAccountViewedProductsByUsernameQuery(req.Username), + query: new GetAccountViewedProductsByUsernameQuery(user.Username), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs index 2ff5a2d7a..e07896816 100644 --- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs +++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs @@ -1,7 +1,8 @@ using CustomCADs.Shared.Application.Abstractions.Requests.Queries; +using CustomCADs.Shared.Domain.TypedIds.Accounts; namespace CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername; public sealed record GetUserByUsernameQuery( - string Username + AccountId Id ) : IQuery; diff --git a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs index 2b01c4f80..2b9e6072d 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs @@ -15,6 +15,17 @@ private async Task MapToUserAsync(AppUser user) ); #region GetUserByX + public async Task GetByAccountIdAsync(AccountId accountId) + { + AppUser appUser = await context.Users + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.AccountId == accountId) + .ConfigureAwait(false) + ?? throw CustomNotFoundException.ByProp(nameof(User.AccountId), accountId); + + return await MapToUserAsync(appUser).ConfigureAwait(false); + } + public async Task GetByUsernameAsync(string username) { AppUser appUser = await context.Users diff --git a/src/Shared/Application/Events/Identity/UserEditedApplicationEvent.cs b/src/Shared/Application/Events/Identity/UserEditedApplicationEvent.cs index ef7ef942e..8dad9bd5b 100644 --- a/src/Shared/Application/Events/Identity/UserEditedApplicationEvent.cs +++ b/src/Shared/Application/Events/Identity/UserEditedApplicationEvent.cs @@ -2,6 +2,9 @@ namespace CustomCADs.Shared.Application.Events.Identity; public record UserEditedApplicationEvent( AccountId Id, + NamesDto? Names = null, string? Username = null, bool? TrackViewedProducts = null ) : BaseApplicationEvent; + +public record NamesDto(string? FirstName = null, string? LastName = null); diff --git a/src/Shared/Application/UseCases/Accounts/Queries/GetAccountExistsByUsernameQuery.cs b/src/Shared/Application/UseCases/Accounts/Queries/GetAccountExistsByUsernameQuery.cs new file mode 100644 index 000000000..0d4e8c566 --- /dev/null +++ b/src/Shared/Application/UseCases/Accounts/Queries/GetAccountExistsByUsernameQuery.cs @@ -0,0 +1,3 @@ +namespace CustomCADs.Shared.Application.UseCases.Accounts.Queries; + +public sealed record GetAccountExistsByUsernameQuery(string Username) : IQuery; diff --git a/src/Shared/Application/UseCases/Accounts/Queries/GetAccountInfoByUsernameQuery.cs b/src/Shared/Application/UseCases/Accounts/Queries/GetAccountInfoByUsernameQuery.cs index c7c2aa922..23510bf65 100644 --- a/src/Shared/Application/UseCases/Accounts/Queries/GetAccountInfoByUsernameQuery.cs +++ b/src/Shared/Application/UseCases/Accounts/Queries/GetAccountInfoByUsernameQuery.cs @@ -5,6 +5,7 @@ string Username ) : IQuery; public sealed record AccountInfoDto( + AccountId Id, DateTimeOffset CreatedAt, bool TrackViewedProducts, string? FirstName, diff --git a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetExists/GetAccountExistsByUsernameHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetExists/GetAccountExistsByUsernameHandlerUnitTests.cs new file mode 100644 index 000000000..a9f3a32f0 --- /dev/null +++ b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetExists/GetAccountExistsByUsernameHandlerUnitTests.cs @@ -0,0 +1,47 @@ +using CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.Exists; +using CustomCADs.Modules.Accounts.Domain.Repositories.Reads; +using CustomCADs.Shared.Application.UseCases.Accounts.Queries; + +namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Queries.Shared.GetExists; + +using static AccountsData; + +public class GetAccountExistsByUsernameHandlerUnitTests : AccountsBaseUnitTests +{ + private readonly GetAccountExistsByUsernameHandler handler; + private readonly Mock reads = new(); + + public GetAccountExistsByUsernameHandlerUnitTests() + { + handler = new(reads.Object); + } + + [Fact] + public async Task Handle_ShouldQueryDatabase() + { + // Arrange + GetAccountExistsByUsernameQuery query = new(ValidUsername); + + // Act + await handler.Handle(query, ct); + + // Assert + reads.Verify(x => x.ExistsByUsernameAsync(ValidUsername, ct), Times.Once()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Handle_ShouldReturnResult(bool exists) + { + // Arrange + reads.Setup(x => x.ExistsByUsernameAsync(ValidUsername, ct)).ReturnsAsync(exists); + GetAccountExistsByUsernameQuery query = new(ValidUsername); + + // Act + bool result = await handler.Handle(query, ct); + + // Assert + Assert.Equal(exists, result); + } +} diff --git a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetInfo/GetAccountInfoByUsernameHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetInfo/GetAccountInfoByUsernameHandlerUnitTests.cs index e67832132..8bf252c0f 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetInfo/GetAccountInfoByUsernameHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/GetInfo/GetAccountInfoByUsernameHandlerUnitTests.cs @@ -45,6 +45,7 @@ public async Task Handle_ShouldReturnResult() // Assert Assert.Multiple( + () => Assert.Equal(account.Id, info.Id), () => Assert.Equal(account.CreatedAt, info.CreatedAt), () => Assert.Equal(account.TrackViewedProducts, info.TrackViewedProducts), () => Assert.Equal(account.FirstName, info.FirstName), diff --git a/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs b/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs index 028bae911..60d83f91a 100644 --- a/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs +++ b/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs @@ -22,6 +22,7 @@ public class ProductViewedHandlerUnitTests : ProductsBaseUnitTests private const string Username = Users.CustomerUsername; private readonly AccountInfoDto info = new( + Id: ValidCreatorId, CreatedAt: default, TrackViewedProducts: true, FirstName: null, diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandlerUnitTests.cs index cbaa26111..7be6ccee3 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/ChangeUsername/ChangeUsernameHandlerUnitTests.cs @@ -13,13 +13,13 @@ public class ChangeUsernameHandlerUnitTests : UsersBaseUnitTests private readonly Mock service = new(); private readonly Mock raiser = new(); - private readonly User user = CreateUser(username: MaxValidUsername); + private readonly User user = CreateUser(); public ChangeUsernameHandlerUnitTests() { handler = new(service.Object, raiser.Object); - service.Setup(x => x.GetByUsernameAsync(user.Username)).ReturnsAsync(user); + service.Setup(x => x.GetByAccountIdAsync(user.AccountId)).ReturnsAsync(user); } [Fact] @@ -27,15 +27,17 @@ public async Task Handle_ShouldCallService() { // Arrange ChangeUsernameCommand command = new( - Username: user.Username, - NewUsername: MinValidUsername + Id: user.AccountId, + Username: MinValidUsername, + FirstName: null, + LastName: null ); // Act await handler.Handle(command, ct); // Assert - service.Verify(x => x.GetByUsernameAsync(MaxValidUsername), Times.Once()); + service.Verify(x => x.GetByAccountIdAsync(user.AccountId), Times.Once()); service.Verify(x => x.UpdateUsernameAsync(user.Id, MinValidUsername), Times.Once()); } @@ -44,8 +46,10 @@ public async Task Handle_ShouldRaiseEvents() { // Arrange ChangeUsernameCommand command = new( - Username: MaxValidUsername, - NewUsername: MinValidUsername + Id: user.AccountId, + Username: MinValidUsername, + FirstName: null, + LastName: null ); // Act diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandlerUnitTests.cs index f14b4f405..8edc5dd57 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandlerUnitTests.cs @@ -26,7 +26,7 @@ public ToggleViewedProductsTrackingHandlerUnitTests() sender.Setup(x => x.SendQueryAsync( It.Is(x => x.Username == MaxValidUsername), ct - )).ReturnsAsync(new AccountInfoDto(default, InitialTrackViewedProducts, null, null)); + )).ReturnsAsync(new AccountInfoDto(ValidAccountId, default, InitialTrackViewedProducts, null, null)); } [Fact] diff --git a/tests/UnitTests/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandlerUnitTests.cs index 51bed3b13..52becb513 100644 --- a/tests/UnitTests/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandlerUnitTests.cs @@ -12,38 +12,39 @@ public class GetUserByUsernameHandlerUnitTests : UsersBaseUnitTests private readonly GetUserByUsernameHandler handler; private readonly Mock service = new(); private readonly Mock sender = new(); + private readonly User user = CreateUserWithId(); public GetUserByUsernameHandlerUnitTests() { handler = new(service.Object, sender.Object); - service.Setup(x => x.GetByUsernameAsync(MaxValidUsername)) - .ReturnsAsync(CreateUserWithId()); + service.Setup(x => x.GetByAccountIdAsync(user.AccountId)) + .ReturnsAsync(user); sender.Setup(x => x.SendQueryAsync( It.Is(x => x.Username == MaxValidUsername), ct - )).ReturnsAsync(new AccountInfoDto(DateTimeOffset.UtcNow, true, null, null)); + )).ReturnsAsync(new AccountInfoDto(ValidAccountId, DateTimeOffset.UtcNow, true, null, null)); } [Fact] public async Task Handle_ShouldCallService() { // Arrange - GetUserByUsernameQuery query = new(MaxValidUsername); + GetUserByUsernameQuery query = new(user.AccountId); // Act await handler.Handle(query, ct); // Assert - service.Verify(x => x.GetByUsernameAsync(MaxValidUsername), Times.Once()); + service.Verify(x => x.GetByAccountIdAsync(user.AccountId), Times.Once()); } [Fact] public async Task Handle_ShouldSendRequests() { // Arrange - GetUserByUsernameQuery query = new(MaxValidUsername); + GetUserByUsernameQuery query = new(user.AccountId); // Act await handler.Handle(query, ct); @@ -63,7 +64,7 @@ public async Task Handle_ShouldSendRequests() public async Task Handle_ShouldReturnResult() { // Arrange - GetUserByUsernameQuery query = new(MaxValidUsername); + GetUserByUsernameQuery query = new(user.AccountId); // Act var result = await handler.Handle(query, ct); From 1a63cf666788da0cc1c29533013c6711eaca4183 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Wed, 29 Apr 2026 19:33:27 +0300 Subject: [PATCH 12/22] Formatted Validation Failures and configured Wolverine and FastEndpoints Validators to use it --- .../Post/Register/RegisterValidator.cs | 19 +++++------ .../ResetPasswordEmailValidator.cs | 4 +-- src/Presentation/ProgramExtensions.cs | 4 +++ .../Validator/ValidationExtensions.cs | 31 +++++++++++++++++ src/Shared/Domain/StringExtensions.cs | 33 +++++++++++++++++++ .../Infrastructure/DependencyInjection.cs | 1 + .../Requests/CustomFailureAction.cs | 11 +++++++ 7 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 src/Shared/Application/Abstractions/Requests/Validator/ValidationExtensions.cs create mode 100644 src/Shared/Domain/StringExtensions.cs create mode 100644 src/Shared/Infrastructure/Requests/CustomFailureAction.cs diff --git a/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs b/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs index 469e8ed4a..fb313056b 100644 --- a/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs +++ b/src/Modules/Identity/API/Identity/Post/Register/RegisterValidator.cs @@ -13,26 +13,23 @@ public class RegisterRequestValidator : Validator public RegisterRequestValidator() { RuleFor(x => x.Role) - .Must(x => x is CustomerRole or ContributorRole) - .WithMessage("""Role must be either "Customer" or "Contributor" """); + .Must(x => x is CustomerRole or ContributorRole).WithMessage("""must be either "Customer" or "Contributor" """); - RuleFor(x => x.Username) + RuleFor(x => x) .MustAsync( - async (username, ct) => !await Resolve() - .GetExistsByUsernameAsync(username) + async (command, ct) => !await Resolve() + .GetExistsByUsernameAsync(command.Username) .ConfigureAwait(false) - ).WithMessage("Cannot register a User with a Duplicate Username"); - - RuleFor(x => x.Email) + ).WithMessage("Cannot register a User with a Duplicate Username") .MustAsync( - async (email, ct) => !await Resolve() - .GetExistsByEmailAsync(email) + async (command, ct) => !await Resolve() + .GetExistsByEmailAsync(command.Email) .ConfigureAwait(false) ).WithMessage("Cannot register a User with a Duplicate Email"); RuleFor(x => x.ConfirmPassword) .NotEmpty().WithMessage(RequiredError) - .Equal(x => x.Password).WithMessage("Passwords must be equal!"); + .Equal(x => x.Password).WithMessage("must be equal to Password!"); } } diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs index 5e5eae742..1414ad7ea 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/ResetPasswordEmail/ResetPasswordEmailValidator.cs @@ -7,9 +7,9 @@ public sealed class ResetPasswordEmailValidator : CommandValidator x.Email) + RuleFor(x => x) .MustAsync( - async (email, ct) => !await service.GetIsSSOByEmailAsync(email).ConfigureAwait(false) + async (command, ct) => !await service.GetIsSSOByEmailAsync(command.Email).ConfigureAwait(false) ) .WithMessage("Accounts with SSO have no Password to Reset."); } diff --git a/src/Presentation/ProgramExtensions.cs b/src/Presentation/ProgramExtensions.cs index eee712afa..e4906fd97 100644 --- a/src/Presentation/ProgramExtensions.cs +++ b/src/Presentation/ProgramExtensions.cs @@ -5,6 +5,7 @@ using CustomCADs.Shared.API; using CustomCADs.Shared.API.Extensions; using CustomCADs.Shared.Domain.TypedIds.Accounts; +using CustomCADs.Shared.Application.Abstractions.Requests.Validator; using FastEndpoints; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -171,9 +172,12 @@ public IApplicationBuilder UseEndpoints() ep.AuthSchemes(AuthScheme); ep.Description(d => d.RequireRateLimiting(APIConstants.RateLimitPolicy)); }; + cfg.Endpoints.RoutePrefix = "api"; cfg.Versioning.DefaultVersion = 1; cfg.Versioning.PrependToRoute = true; + + cfg.Errors.ResponseBuilder = (failures, _, _) => throw failures.ValidationException; }); public IApplicationBuilder UseDisableBrowserCaching() diff --git a/src/Shared/Application/Abstractions/Requests/Validator/ValidationExtensions.cs b/src/Shared/Application/Abstractions/Requests/Validator/ValidationExtensions.cs new file mode 100644 index 000000000..64109f75a --- /dev/null +++ b/src/Shared/Application/Abstractions/Requests/Validator/ValidationExtensions.cs @@ -0,0 +1,31 @@ +namespace CustomCADs.Shared.Application.Abstractions.Requests.Validator; + +using Shared.Domain; + +public static class ValidationExtensions +{ + extension(IEnumerable failures) + { + public FluentValidation.ValidationException ValidationException => new( + message: string.Join( + separator: ".\n", + values: failures + .Select(f => + { + f.PropertyName = f.PropertyName.AsCapitalized(); + f.ErrorMessage = f.ErrorMessage.AsCapitalized(); + return f; + }) + .GroupBy(f => f.PropertyName) + .ToDictionary( + f => f.Key, + f => string.Join( + separator: "; ", + values: f.Select((x) => x.ErrorMessage).Distinct() + ).Trim() + ) + .Select(e => string.IsNullOrWhiteSpace(e.Key) ? e.Value : $"{e.Key}: {e.Value}") + ) + '.' + ); + } +} diff --git a/src/Shared/Domain/StringExtensions.cs b/src/Shared/Domain/StringExtensions.cs new file mode 100644 index 000000000..7e33f1b09 --- /dev/null +++ b/src/Shared/Domain/StringExtensions.cs @@ -0,0 +1,33 @@ +namespace CustomCADs.Shared.Domain; + +public static class StringExtensions +{ + extension(string value) + { + public string AsCapitalized(bool allUppercase = false, bool dismantlePascal = true) + { + if (value.Length == 0) return value; + + System.Text.StringBuilder result = new(value); + result[0] = char.ToUpper(result[0]); + + if (dismantlePascal) for (int i = 1; i < result.Length; ++i) + { + if (char.IsLower(result[i - 1]) && char.IsUpper(result[i])) + { + result.Insert(i, ' '); + } + } + + if (allUppercase) for (int i = 1; i < result.Length; ++i) + { + if (char.IsWhiteSpace(result[i - 1]) && !char.IsWhiteSpace(result[i])) + { + result[i] = char.ToUpper(result[i]); + } + } + + return result.ToString(); + } + } +} diff --git a/src/Shared/Infrastructure/DependencyInjection.cs b/src/Shared/Infrastructure/DependencyInjection.cs index 77785580b..3beb0500c 100644 --- a/src/Shared/Infrastructure/DependencyInjection.cs +++ b/src/Shared/Infrastructure/DependencyInjection.cs @@ -65,6 +65,7 @@ public void AddMessagingServices(bool codeGen, Assembly entry, params Assembly[] } cfg.UseFluentValidation(); + cfg.Services.AddSingleton(typeof(IFailureAction<>), typeof(CustomFailureAction<>)); }); services.AddScoped(); diff --git a/src/Shared/Infrastructure/Requests/CustomFailureAction.cs b/src/Shared/Infrastructure/Requests/CustomFailureAction.cs new file mode 100644 index 000000000..84518cc43 --- /dev/null +++ b/src/Shared/Infrastructure/Requests/CustomFailureAction.cs @@ -0,0 +1,11 @@ +using CustomCADs.Shared.Application.Abstractions.Requests.Validator; +using FluentValidation.Results; +using Wolverine.FluentValidation; + +namespace CustomCADs.Shared.Infrastructure.Requests; + +public class CustomFailureAction : IFailureAction +{ + public void Throw(T message, IReadOnlyList failures) + => throw failures.ValidationException; +} From 43f49eead7afae6b0e7a50b6ec0bbf23145cb67c Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 2 May 2026 03:25:30 +0300 Subject: [PATCH 13/22] EF Core DI and Names assignment fixes --- .../Accounts/Events/Application/UserEditedHandler.cs | 10 +++++++--- .../Identity/Infrastructure/DependencyInjection.cs | 8 ++++++-- .../Identity/Services/AppUserService.Writes.cs | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs b/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs index bf25739cd..5486874dc 100644 --- a/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs +++ b/src/Modules/Accounts/Application/Accounts/Events/Application/UserEditedHandler.cs @@ -13,8 +13,12 @@ public async Task HandleAsync(UserEditedApplicationEvent ae) if (ae.Username is not null) { - account.SetUsername(ae.Username); + if (ae.Username != account.Username) + { + account.SetUsername(ae.Username); + } } + if (ae.TrackViewedProducts is not null) { account.SetTrackViewedProducts(ae.TrackViewedProducts.Value); @@ -22,11 +26,11 @@ public async Task HandleAsync(UserEditedApplicationEvent ae) if (ae.Names is not null) { - if (ae.Names.FirstName is not null) + if (ae.Names.FirstName != account.FirstName) { account.SetFirstName(ae.Names.FirstName); } - if (ae.Names.LastName is not null) + if (ae.Names.LastName != account.LastName) { account.SetLastName(ae.Names.LastName); } diff --git a/src/Modules/Identity/Infrastructure/DependencyInjection.cs b/src/Modules/Identity/Infrastructure/DependencyInjection.cs index f5396509b..024e7cc1e 100644 --- a/src/Modules/Identity/Infrastructure/DependencyInjection.cs +++ b/src/Modules/Identity/Infrastructure/DependencyInjection.cs @@ -36,9 +36,13 @@ public IServiceCollection AddIdentityServices(string connectionString) private IServiceCollection AddContext(string connectionString) { - services.AddDbContext(options => + services.AddSingleton( + sp => new NpgsqlDataSourceBuilder(connectionString).EnableDynamicJson().Build() + ); + + services.AddDbContext((sp, options) => options.UseNpgsql( - dataSource: new NpgsqlDataSourceBuilder(connectionString).EnableDynamicJson().Build(), + dataSource: sp.GetRequiredService(), npgsqlOptionsAction: opt => opt.MigrationsHistoryTable("__EFMigrationsHistory", IdentityContext.Schema) ) ); diff --git a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs index eea3c0101..6bb4c6a89 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs @@ -68,7 +68,7 @@ public async Task UpdateUsernameAsync(UserId id, string username) AppUser appUser = await manager.FindByIdAsync(id.ToString()).ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(id), id); - appUser.Username = username; + if (appUser.Username != username) appUser.Username = username; await manager.UpdateAsync(appUser).ConfigureAwait(false); } From 7caa80c7cb591d4663f1e0fffd9bd6f7196a427d Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sat, 2 May 2026 03:37:41 +0300 Subject: [PATCH 14/22] Greatly expanded ViewedProduct feature - Started requiring a specific header in order to mark a Product as Viewed - Moved from ShadowEntity to Domain - Added ViewedAt field - Started returning DTOs instead of IDs - Allowed User to 'Unview' a Product --- .../src/api/catalog/products/gallery/index.ts | 4 +- .../api/catalog/products/gallery/single.ts | 1 + sdk/js/react/src/api/identity/common.ts | 5 + .../identity/delete-viewed-product.ts | 7 + .../react/src/api/identity/identity/index.ts | 11 +- .../src/api/identity/identity/my-account.ts | 3 +- .../react/src/api/identity/identity/types.ts | 1 + .../src/hooks/mutations/identity/identity.ts | 6 + .../Shared/DeleteViewedProductHandler.cs | 14 ++ .../Application/UserViewedProductHandler.cs | 2 +- ...tAccountViewedProductsByUsernameHandler.cs | 12 - .../GetAccountViewedProductHandler.cs | 2 +- ...tAccountViewedProductsByUsernameHandler.cs | 20 ++ .../Accounts/Domain/Accounts/Account.cs | 5 + .../Domain/Accounts/Entities/ViewedProduct.cs | 22 ++ .../Repositories/Reads/IAccountReads.cs | 3 +- .../Repositories/Writes/IAccountWrites.cs | 3 +- .../Accounts/Persistence/AccountsContext.cs | 2 +- .../ViewedProducts/Configurations.cs | 2 +- .../ViewedProducts/Utilities.cs | 4 +- ..._Added_ViewedProducts_ViewedAt.Designer.cs | 225 ++++++++++++++++++ ...501233422_Added_ViewedProducts_ViewedAt.cs | 30 +++ .../AccountsContextModelSnapshot.cs | 38 +-- .../Repositories/Accounts/Reads.cs | 25 +- .../Repositories/Accounts/Utilities.cs | 20 -- .../Repositories/Accounts/Writes.cs | 18 +- .../ShadowEntities/ViewedProduct.cs | 16 -- .../Get/Single/GetGalleryProductEndpoint.cs | 5 +- .../ProductViewedApplicationEvent.cs | 3 +- .../ProductViewed/ProductViewedHandler.cs | 34 ++- .../GetById/GalleryGetProductByIdHandler.cs | 5 +- .../GetById/GalleryGetProductByIdQuery.cs | 3 +- .../API/Dtos/ViewedProductResponse.cs | 3 + .../DeleteViewedProductEndpoint.cs | 31 +++ .../DeleteViewedProductRequest.cs | 3 + .../Get/DownloadInfo/DownloadInfoEndpoint.cs | 2 +- .../Identity/Get/MyAccount/MyAccountMapper.cs | 11 +- .../Get/MyAccount/MyAccountResponse.cs | 5 +- .../GetByUsername/GetUserByUsernameDto.cs | 4 +- .../GetByUsername/GetUserByUsernameHandler.cs | 5 +- .../UserViewedProductApplicationEvent.cs | 3 +- .../Commands/DeleteViewedProductCommand.cs | 6 + ...GetAccountViewedProductsByUsernameQuery.cs | 7 +- .../UserViewedProductHandlerUnitTests.cs | 5 +- ...GetAccountViewedProductHandlerUnitTests.cs | 4 +- ...iewedProductsByUsernameHandlerUnitTests.cs | 12 +- .../ProductViewedHandlerUnitTests.cs | 17 +- .../GalleryGetProductByIdHandlerUnitTests.cs | 38 ++- 48 files changed, 547 insertions(+), 160 deletions(-) create mode 100644 sdk/js/react/src/api/identity/identity/delete-viewed-product.ts create mode 100644 src/Modules/Accounts/Application/Accounts/Commands/Shared/DeleteViewedProductHandler.cs delete mode 100644 src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandler.cs rename src/Modules/Accounts/Application/Accounts/Queries/Shared/{ViewedProduct => ViewedProducts}/GetAccountViewedProductHandler.cs (95%) create mode 100644 src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandler.cs create mode 100644 src/Modules/Accounts/Domain/Accounts/Entities/ViewedProduct.cs create mode 100644 src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.Designer.cs create mode 100644 src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.cs delete mode 100644 src/Modules/Accounts/Persistence/ShadowEntities/ViewedProduct.cs create mode 100644 src/Modules/Identity/API/Dtos/ViewedProductResponse.cs create mode 100644 src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductEndpoint.cs create mode 100644 src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductRequest.cs create mode 100644 src/Shared/Application/UseCases/Accounts/Commands/DeleteViewedProductCommand.cs rename tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/{ViewedProduct => ViewedProducts}/GetAccountViewedProductHandlerUnitTests.cs (96%) rename tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/{ViewedProduct => ViewedProducts}/GetAccountViewedProductsByUsernameHandlerUnitTests.cs (78%) diff --git a/sdk/js/react/src/api/catalog/products/gallery/index.ts b/sdk/js/react/src/api/catalog/products/gallery/index.ts index aa6006b19..daa90685c 100644 --- a/sdk/js/react/src/api/catalog/products/gallery/index.ts +++ b/sdk/js/react/src/api/catalog/products/gallery/index.ts @@ -8,7 +8,9 @@ export const all = async (req: allResources.Request) => await axios.get>(allResources.url(req)); export const single = async (req: singleResources.Request) => - await axios.get(singleResources.url(req)); + await axios.get(singleResources.url(req), { + headers: { 'X-Viewed-Product': req.viewed }, + }); export const sortings = async () => await axios.get(sortingsResources.url()); diff --git a/sdk/js/react/src/api/catalog/products/gallery/single.ts b/sdk/js/react/src/api/catalog/products/gallery/single.ts index c5452dc23..7db49d231 100644 --- a/sdk/js/react/src/api/catalog/products/gallery/single.ts +++ b/sdk/js/react/src/api/catalog/products/gallery/single.ts @@ -2,6 +2,7 @@ import { CategoryDto, Counts, GALLERY_BASE_PATH } from '@/api/catalog/common'; export type Request = { id: string; + viewed?: boolean; }; export type Response = { diff --git a/sdk/js/react/src/api/identity/common.ts b/sdk/js/react/src/api/identity/common.ts index 0bf3b2980..388ecf776 100644 --- a/sdk/js/react/src/api/identity/common.ts +++ b/sdk/js/react/src/api/identity/common.ts @@ -1 +1,6 @@ +export type ViewedProduct = { + id: string; + viewedAt: string; +}; + export const IDENTITY_BASE_PATH = '/identity'; diff --git a/sdk/js/react/src/api/identity/identity/delete-viewed-product.ts b/sdk/js/react/src/api/identity/identity/delete-viewed-product.ts new file mode 100644 index 000000000..9a3225e41 --- /dev/null +++ b/sdk/js/react/src/api/identity/identity/delete-viewed-product.ts @@ -0,0 +1,7 @@ +import { IDENTITY_BASE_PATH } from '../common'; + +export type Request = { + productId: string; +}; + +export const url = () => `${IDENTITY_BASE_PATH}/viewed-product`; diff --git a/sdk/js/react/src/api/identity/identity/index.ts b/sdk/js/react/src/api/identity/identity/index.ts index 5dc95bbf4..67b7aca81 100644 --- a/sdk/js/react/src/api/identity/identity/index.ts +++ b/sdk/js/react/src/api/identity/identity/index.ts @@ -1,4 +1,4 @@ -import { axios } from '@/api/axios'; +import { axios, config } from '@/api/axios'; import * as authnResources from './authn'; import * as authzResources from './authz'; import * as myAccountResources from './my-account'; @@ -7,6 +7,7 @@ import * as loginResources from './login'; import * as refreshResources from './refresh'; import * as logoutResources from './logout'; import * as deleteResources from './delete'; +import * as deleteViewedProductResources from './delete-viewed-product'; import * as changeNamesResources from './change-names'; import * as toggleTrackViewedProductsResources from './toggle-track-viewed-products'; import * as forgotPasswordResources from './forgot-password'; @@ -45,6 +46,14 @@ export const toggleTrackViewedProducts = async () => export const delete_ = async () => await axios.delete(deleteResources.url()); +export const deleteViewedProduct = async ( + req: deleteViewedProductResources.Request, +) => + await axios.delete( + deleteViewedProductResources.url(), + config({ data: req }), + ); + export const forgotPassword = async (req: forgotPasswordResources.Request) => await axios.post(forgotPasswordResources.url(), req); diff --git a/sdk/js/react/src/api/identity/identity/my-account.ts b/sdk/js/react/src/api/identity/identity/my-account.ts index a4db8d8a8..e7e3c13e2 100644 --- a/sdk/js/react/src/api/identity/identity/my-account.ts +++ b/sdk/js/react/src/api/identity/identity/my-account.ts @@ -1,4 +1,4 @@ -import { IDENTITY_BASE_PATH } from '../common'; +import { IDENTITY_BASE_PATH, ViewedProduct } from '../common'; export type Response = { id: string; @@ -8,6 +8,7 @@ export type Response = { lastName?: string; email: string; trackViewedProducts: boolean; + viewedProducts: ViewedProduct[]; createdAt: string; }; diff --git a/sdk/js/react/src/api/identity/identity/types.ts b/sdk/js/react/src/api/identity/identity/types.ts index 495b8380c..331d7fb93 100644 --- a/sdk/js/react/src/api/identity/identity/types.ts +++ b/sdk/js/react/src/api/identity/identity/types.ts @@ -1,3 +1,4 @@ +export type { ViewedProduct } from '../common'; export type { Response as AuthnResponse } from './authn'; export type { Response as AuthzResponse } from './authz'; export type { Request as ChangeNamesRequest } from './change-names'; diff --git a/sdk/js/react/src/hooks/mutations/identity/identity.ts b/sdk/js/react/src/hooks/mutations/identity/identity.ts index bd709fd00..a4a2b5806 100644 --- a/sdk/js/react/src/hooks/mutations/identity/identity.ts +++ b/sdk/js/react/src/hooks/mutations/identity/identity.ts @@ -4,6 +4,7 @@ import { Request as Login } from '@/api/identity/identity/login'; import { Request as ForgotPassword } from '@/api/identity/identity/forgot-password'; import { Request as ResetPassword } from '@/api/identity/identity/reset-password'; import { Request as ChangeNames } from '@/api/identity/identity/change-names'; +import { Request as DeleteViewedProduct } from '@/api/identity/identity/delete-viewed-product'; import { Request as Register } from '@/api/identity/identity/register'; import { Request as ConfirmEmail } from '@/api/identity/identity/confirm-email'; import { Request as RetryConfirmEmail } from '@/api/identity/identity/retry-confirm-email'; @@ -35,6 +36,11 @@ export const identity = { mutationKey: [...BASE_KEY, 'delete-my-account'], mutationFn: async () => (await api.delete_()).data, }), + deleteViewedProduct: mutationOptions({ + mutationKey: [...BASE_KEY, 'delete-viewed-product'], + mutationFn: async (params: DeleteViewedProduct) => + (await api.deleteViewedProduct(params)).data, + }), forgotPassword: mutationOptions({ mutationKey: [...BASE_KEY, 'forgot-password'], mutationFn: async (params: ForgotPassword) => diff --git a/src/Modules/Accounts/Application/Accounts/Commands/Shared/DeleteViewedProductHandler.cs b/src/Modules/Accounts/Application/Accounts/Commands/Shared/DeleteViewedProductHandler.cs new file mode 100644 index 000000000..bf1ad0a2f --- /dev/null +++ b/src/Modules/Accounts/Application/Accounts/Commands/Shared/DeleteViewedProductHandler.cs @@ -0,0 +1,14 @@ +using CustomCADs.Modules.Accounts.Domain.Repositories; +using CustomCADs.Modules.Accounts.Domain.Repositories.Writes; +using CustomCADs.Shared.Application.UseCases.Accounts.Commands; + +namespace CustomCADs.Modules.Accounts.Application.Accounts.Commands.Shared; + +public class DeleteViewedProductHandler(IAccountWrites writes, IUnitOfWork uow) : ICommandHandler +{ + public async Task Handle(DeleteViewedProductCommand req, CancellationToken ct = default) + { + await writes.UnviewProductAsync(req.CallerId, req.ProductId, ct).ConfigureAwait(false); + await uow.SaveChangesAsync(ct).ConfigureAwait(false); + } +} diff --git a/src/Modules/Accounts/Application/Accounts/Events/Application/UserViewedProductHandler.cs b/src/Modules/Accounts/Application/Accounts/Events/Application/UserViewedProductHandler.cs index d634d5405..f67e576cb 100644 --- a/src/Modules/Accounts/Application/Accounts/Events/Application/UserViewedProductHandler.cs +++ b/src/Modules/Accounts/Application/Accounts/Events/Application/UserViewedProductHandler.cs @@ -8,7 +8,7 @@ public class UserViewedProductHandler(IAccountWrites writes, IUnitOfWork uow) { public async Task HandleAsync(UserViewedProductApplicationEvent ae) { - await writes.ViewProductAsync(ae.AccountId, ae.Id).ConfigureAwait(false); + await writes.ViewProductAsync(ae.AccountId, ae.Id, ae.ViewedAt).ConfigureAwait(false); await uow.SaveChangesAsync().ConfigureAwait(false); } } diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandler.cs deleted file mode 100644 index 28501e900..000000000 --- a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandler.cs +++ /dev/null @@ -1,12 +0,0 @@ -using CustomCADs.Modules.Accounts.Domain.Repositories.Reads; -using CustomCADs.Shared.Application.UseCases.Accounts.Queries; -using CustomCADs.Shared.Domain.TypedIds.Catalog; - -namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProduct; - -public sealed class GetAccountViewedProductsByUsernameHandler(IAccountReads reads) - : IQueryHandler -{ - public async Task Handle(GetAccountViewedProductsByUsernameQuery req, CancellationToken ct) - => await reads.ViewedProductsByUsernameAsync(req.Username, ct).ConfigureAwait(false); -} diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandler.cs similarity index 95% rename from src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandler.cs rename to src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandler.cs index ebad33cc8..0fa00dc42 100644 --- a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandler.cs +++ b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandler.cs @@ -2,7 +2,7 @@ using CustomCADs.Shared.Application.UseCases.Accounts.Queries; using CustomCADs.Shared.Domain.TypedIds.Catalog; -namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProduct; +namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProducts; public sealed class GetAccountViewedProductHandler(IAccountReads reads) : IQueryHandler diff --git a/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandler.cs b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandler.cs new file mode 100644 index 000000000..eadcd6560 --- /dev/null +++ b/src/Modules/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandler.cs @@ -0,0 +1,20 @@ +using CustomCADs.Modules.Accounts.Domain.Accounts.Entities; +using CustomCADs.Modules.Accounts.Domain.Repositories.Reads; +using CustomCADs.Shared.Application.UseCases.Accounts.Queries; + +namespace CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProducts; + +public sealed class GetAccountViewedProductsByUsernameHandler(IAccountReads reads) + : IQueryHandler +{ + public async Task Handle(GetAccountViewedProductsByUsernameQuery req, CancellationToken ct) + { + ViewedProduct[] viewedProducts = await reads + .ViewedProductsByUsernameAsync(req.Username, ct) + .ConfigureAwait(false); + + return [.. viewedProducts + .Select(x => new ViewedProductDto(x.ProductId, x.ViewedAt)) + ]; + } +} diff --git a/src/Modules/Accounts/Domain/Accounts/Account.cs b/src/Modules/Accounts/Domain/Accounts/Account.cs index 9ec8f1c58..c73a5e4a9 100644 --- a/src/Modules/Accounts/Domain/Accounts/Account.cs +++ b/src/Modules/Accounts/Domain/Accounts/Account.cs @@ -2,8 +2,12 @@ namespace CustomCADs.Modules.Accounts.Domain.Accounts; +using Entities; + public class Account : BaseAggregateRoot, ISoftDeletable { + private readonly List viewedProducts = []; + private Account() { } private Account( string role, @@ -32,6 +36,7 @@ private Account( public DateTimeOffset CreatedAt { get; private set; } public bool IsDeleted { get; private set; } public DateTimeOffset? DeletedAt { get; private set; } + public IReadOnlyCollection ViewedProducts => viewedProducts; public static Account Create( string role, diff --git a/src/Modules/Accounts/Domain/Accounts/Entities/ViewedProduct.cs b/src/Modules/Accounts/Domain/Accounts/Entities/ViewedProduct.cs new file mode 100644 index 000000000..00be4dba1 --- /dev/null +++ b/src/Modules/Accounts/Domain/Accounts/Entities/ViewedProduct.cs @@ -0,0 +1,22 @@ +using CustomCADs.Shared.Domain.TypedIds.Catalog; + +namespace CustomCADs.Modules.Accounts.Domain.Accounts.Entities; + +public class ViewedProduct +{ + private ViewedProduct() { } + private ViewedProduct(AccountId id, ProductId productId, DateTimeOffset viewedAt) : base() + { + AccountId = id; + ProductId = productId; + ViewedAt = viewedAt; + } + + public AccountId AccountId { get; init; } + public ProductId ProductId { get; init; } + public DateTimeOffset ViewedAt { get; init; } + public Account Account { get; init; } = null!; + + public static ViewedProduct Create(AccountId id, ProductId productId, DateTimeOffset viewedAt) + => new(id, productId, viewedAt); +} diff --git a/src/Modules/Accounts/Domain/Repositories/Reads/IAccountReads.cs b/src/Modules/Accounts/Domain/Repositories/Reads/IAccountReads.cs index 718ee3812..a187df136 100644 --- a/src/Modules/Accounts/Domain/Repositories/Reads/IAccountReads.cs +++ b/src/Modules/Accounts/Domain/Repositories/Reads/IAccountReads.cs @@ -1,4 +1,5 @@ using CustomCADs.Modules.Accounts.Domain.Accounts; +using CustomCADs.Modules.Accounts.Domain.Accounts.Entities; using CustomCADs.Shared.Domain.Querying; using CustomCADs.Shared.Domain.TypedIds.Catalog; @@ -13,6 +14,6 @@ public interface IAccountReads Task ExistsByIdAsync(AccountId id, CancellationToken ct = default); Task ExistsByUsernameAsync(string username, CancellationToken ct = default); Task ViewedProductsByIdAsync(AccountId id, CancellationToken ct = default); - Task ViewedProductsByUsernameAsync(string username, CancellationToken ct = default); + Task ViewedProductsByUsernameAsync(string username, CancellationToken ct = default); Task CountAsync(CancellationToken ct = default); } diff --git a/src/Modules/Accounts/Domain/Repositories/Writes/IAccountWrites.cs b/src/Modules/Accounts/Domain/Repositories/Writes/IAccountWrites.cs index 5ffb9d063..ec76eb4ba 100644 --- a/src/Modules/Accounts/Domain/Repositories/Writes/IAccountWrites.cs +++ b/src/Modules/Accounts/Domain/Repositories/Writes/IAccountWrites.cs @@ -6,6 +6,7 @@ namespace CustomCADs.Modules.Accounts.Domain.Repositories.Writes; public interface IAccountWrites { Task AddAsync(Account entity, CancellationToken ct = default); - Task ViewProductAsync(AccountId id, ProductId productId, CancellationToken ct = default); + Task ViewProductAsync(AccountId id, ProductId productId, DateTimeOffset viewedAt, CancellationToken ct = default); + Task UnviewProductAsync(AccountId id, ProductId productId, CancellationToken ct = default); void Remove(Account entity); } diff --git a/src/Modules/Accounts/Persistence/AccountsContext.cs b/src/Modules/Accounts/Persistence/AccountsContext.cs index 4121f53b7..34f811979 100644 --- a/src/Modules/Accounts/Persistence/AccountsContext.cs +++ b/src/Modules/Accounts/Persistence/AccountsContext.cs @@ -1,6 +1,6 @@ using CustomCADs.Modules.Accounts.Domain.Accounts; +using CustomCADs.Modules.Accounts.Domain.Accounts.Entities; using CustomCADs.Modules.Accounts.Domain.Roles; -using CustomCADs.Modules.Accounts.Persistence.ShadowEntities; using CustomCADs.Shared.Persistence; namespace CustomCADs.Modules.Accounts.Persistence; diff --git a/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Configurations.cs b/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Configurations.cs index 030e2659c..df93f1db7 100644 --- a/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Configurations.cs +++ b/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Configurations.cs @@ -1,4 +1,4 @@ -using CustomCADs.Modules.Accounts.Persistence.ShadowEntities; +using CustomCADs.Modules.Accounts.Domain.Accounts.Entities; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace CustomCADs.Modules.Accounts.Persistence.Configurations.ViewedProducts; diff --git a/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Utilities.cs b/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Utilities.cs index 5deeca6b5..b7626c271 100644 --- a/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Utilities.cs +++ b/src/Modules/Accounts/Persistence/Configurations/ViewedProducts/Utilities.cs @@ -1,4 +1,4 @@ -using CustomCADs.Modules.Accounts.Persistence.ShadowEntities; +using CustomCADs.Modules.Accounts.Domain.Accounts.Entities; using CustomCADs.Shared.Domain.TypedIds.Catalog; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -19,7 +19,7 @@ internal EntityTypeBuilder SetForeignKeys() { builder .HasOne(x => x.Account) - .WithMany() + .WithMany(x => x.ViewedProducts) .HasForeignKey(x => x.AccountId) .OnDelete(DeleteBehavior.Cascade); diff --git a/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.Designer.cs b/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.Designer.cs new file mode 100644 index 000000000..9cc69e0bf --- /dev/null +++ b/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.Designer.cs @@ -0,0 +1,225 @@ +// +using System; +using CustomCADs.Modules.Accounts.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CustomCADs.Modules.Accounts.Persistence.Migrations +{ + [DbContext(typeof(AccountsContext))] + [Migration("20260501233422_Added_ViewedProducts_ViewedAt")] + partial class Added_ViewedProducts_ViewedAt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Accounts") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("DeletedAt"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Email"); + + b.Property("FirstName") + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("FirstName"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("IsDeleted"); + + b.Property("LastName") + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("LastName"); + + b.Property("RoleName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("RoleName"); + + b.Property("TrackViewedProducts") + .HasColumnType("boolean") + .HasColumnName("TrackViewedProducts"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(62) + .HasColumnType("character varying(62)") + .HasColumnName("Username"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Accounts", "Accounts"); + + b.HasData( + new + { + Id = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 10, 19, 23, 12, 123, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "ivanzlatinov006@gmail.com", + IsDeleted = false, + RoleName = "Customer", + TrackViewedProducts = true, + Username = "For7a7a" + }, + new + { + Id = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 5, 13, 17, 42, 57, 456, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "PDMatsaliev20@codingburgas.bg", + IsDeleted = false, + RoleName = "Contributor", + TrackViewedProducts = true, + Username = "PDMatsaliev20" + }, + new + { + Id = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), + CreatedAt = new DateTimeOffset(new DateTime(2025, 1, 9, 13, 15, 28, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "john.cad@gmail.com", + IsDeleted = false, + RoleName = "Designer", + TrackViewedProducts = true, + Username = "John_CAD" + }, + new + { + Id = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 17, 32, 789, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "boriskolev2006@gmail.com", + IsDeleted = false, + RoleName = "Designer", + TrackViewedProducts = true, + Username = "Oracle3000" + }, + new + { + Id = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"), + CreatedAt = new DateTimeOffset(new DateTime(2024, 3, 17, 2, 45, 13, 0, DateTimeKind.Unspecified), new TimeSpan(0, 3, 0, 0, 0)), + Email = "ivanangelov414@gmail.com", + IsDeleted = false, + RoleName = "Administrator", + TrackViewedProducts = true, + Username = "NinjataBG" + }); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Entities.ViewedProduct", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ViewedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AccountId", "ProductId"); + + b.ToTable("ViewedProducts", "Accounts"); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("Description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("Name"); + + b.HasKey("Id"); + + b.ToTable("Roles", "Accounts"); + + b.HasData( + new + { + Id = 1, + Description = "Can buy Products from the Gallery as Cart Items; Can request Customs from our Designers and contact them; Can download purchased CADs and track requested Shipments.", + Name = "Customer" + }, + new + { + Id = 2, + Description = "Can upload 3D Models to the Gallery as Products; Can sell CADs to our Designers and contact them; Can apply to become a Designer himself.", + Name = "Contributor" + }, + new + { + Id = 3, + Description = "Can accept and work on Customers' Customs; Can validate or report Contributors' Products; Can do everything a Contributor can do.", + Name = "Designer" + }, + new + { + Id = 4, + Description = "Can access all non-sensitive info from all resources; Can ban reported resources - Customs, Products, Users, ...; Can modify Categories and Roles.", + Name = "Administrator" + }); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Entities.ViewedProduct", b => + { + b.HasOne("CustomCADs.Modules.Accounts.Domain.Accounts.Account", "Account") + .WithMany("ViewedProducts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b => + { + b.Navigation("ViewedProducts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.cs b/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.cs new file mode 100644 index 000000000..4ff31e4ae --- /dev/null +++ b/src/Modules/Accounts/Persistence/Migrations/20260501233422_Added_ViewedProducts_ViewedAt.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CustomCADs.Modules.Accounts.Persistence.Migrations; + +/// +public partial class Added_ViewedProducts_ViewedAt : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ViewedAt", + schema: "Accounts", + table: "ViewedProducts", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ViewedAt", + schema: "Accounts", + table: "ViewedProducts"); + } +} diff --git a/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs b/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs index 974878234..d842d34f9 100644 --- a/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs +++ b/src/Modules/Accounts/Persistence/Migrations/AccountsContextModelSnapshot.cs @@ -134,6 +134,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Entities.ViewedProduct", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("ProductId") + .HasColumnType("uuid"); + + b.Property("ViewedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AccountId", "ProductId"); + + b.ToTable("ViewedProducts", "Accounts"); + }); + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Roles.Role", b => { b.Property("Id") @@ -185,29 +201,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b => - { - b.Property("AccountId") - .HasColumnType("uuid"); - - b.Property("ProductId") - .HasColumnType("uuid"); - - b.HasKey("AccountId", "ProductId"); - - b.ToTable("ViewedProducts", "Accounts"); - }); - - modelBuilder.Entity("CustomCADs.Modules.Accounts.Persistence.ShadowEntities.ViewedProduct", b => + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Entities.ViewedProduct", b => { b.HasOne("CustomCADs.Modules.Accounts.Domain.Accounts.Account", "Account") - .WithMany() + .WithMany("ViewedProducts") .HasForeignKey("AccountId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("Account"); }); + + modelBuilder.Entity("CustomCADs.Modules.Accounts.Domain.Accounts.Account", b => + { + b.Navigation("ViewedProducts"); + }); #pragma warning restore 612, 618 } } diff --git a/src/Modules/Accounts/Persistence/Repositories/Accounts/Reads.cs b/src/Modules/Accounts/Persistence/Repositories/Accounts/Reads.cs index 0af52b114..4cb45fe90 100644 --- a/src/Modules/Accounts/Persistence/Repositories/Accounts/Reads.cs +++ b/src/Modules/Accounts/Persistence/Repositories/Accounts/Reads.cs @@ -1,4 +1,5 @@ using CustomCADs.Modules.Accounts.Domain.Accounts; +using CustomCADs.Modules.Accounts.Domain.Accounts.Entities; using CustomCADs.Modules.Accounts.Domain.Repositories.Reads; using CustomCADs.Shared.Domain.Querying; using CustomCADs.Shared.Domain.TypedIds.Catalog; @@ -57,14 +58,24 @@ public async Task ExistsByUsernameAsync(string username, CancellationToken .ConfigureAwait(false); public async Task ViewedProductsByIdAsync(AccountId id, CancellationToken ct = default) - => await context.ViewedProducts - .GetViewedProductsByAccountIdAsync(id, ct) - .ConfigureAwait(false); + => [.. await context.Accounts + .Where(x => x.Id == id) + .SelectMany(x => x.ViewedProducts) + .Select(x => x.ProductId) + .ToArrayAsync(ct) + .ConfigureAwait(false) + ?? [] + ]; - public async Task ViewedProductsByUsernameAsync(string username, CancellationToken ct = default) - => await context.ViewedProducts - .GetViewedProductsByAccountUsernrameAsync(username, ct) - .ConfigureAwait(false); + public async Task ViewedProductsByUsernameAsync(string username, CancellationToken ct = default) + => [.. await context.Accounts + .Where(x => x.Username == username) + .SelectMany(x => x.ViewedProducts) + .OrderByDescending(x => x.ViewedAt) + .ToArrayAsync(ct) + .ConfigureAwait(false) + ?? [] + ]; public async Task CountAsync(CancellationToken ct = default) => await context.Accounts diff --git a/src/Modules/Accounts/Persistence/Repositories/Accounts/Utilities.cs b/src/Modules/Accounts/Persistence/Repositories/Accounts/Utilities.cs index 751a35d98..adeb6a913 100644 --- a/src/Modules/Accounts/Persistence/Repositories/Accounts/Utilities.cs +++ b/src/Modules/Accounts/Persistence/Repositories/Accounts/Utilities.cs @@ -1,8 +1,6 @@ using CustomCADs.Modules.Accounts.Domain.Accounts; using CustomCADs.Modules.Accounts.Domain.Accounts.Enums; -using CustomCADs.Modules.Accounts.Persistence.ShadowEntities; using CustomCADs.Shared.Domain.Extensions; -using CustomCADs.Shared.Domain.TypedIds.Catalog; using CustomCADs.Shared.Domain.ValueObjects; namespace CustomCADs.Modules.Accounts.Persistence.Repositories.Accounts; @@ -56,22 +54,4 @@ internal IQueryable WithSorting(Sorting? sorting = _ => query, }; } - - extension(DbSet set) - { - internal async Task GetViewedProductsByAccountIdAsync(AccountId id, CancellationToken ct = default) - => await set - .Where(x => x.AccountId == id) - .Select(x => x.ProductId) - .ToArrayAsync(ct) - .ConfigureAwait(false); - - internal async Task GetViewedProductsByAccountUsernrameAsync(string username, CancellationToken ct = default) - => await set - .Include(x => x.Account) - .Where(x => x.Account.Username == username) - .Select(x => x.ProductId) - .ToArrayAsync(ct) - .ConfigureAwait(false); - } } diff --git a/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs b/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs index b4f400b42..841217d37 100644 --- a/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs +++ b/src/Modules/Accounts/Persistence/Repositories/Accounts/Writes.cs @@ -1,6 +1,6 @@ using CustomCADs.Modules.Accounts.Domain.Accounts; +using CustomCADs.Modules.Accounts.Domain.Accounts.Entities; using CustomCADs.Modules.Accounts.Domain.Repositories.Writes; -using CustomCADs.Modules.Accounts.Persistence.ShadowEntities; using CustomCADs.Shared.Domain.TypedIds.Catalog; namespace CustomCADs.Modules.Accounts.Persistence.Repositories.Accounts; @@ -10,12 +10,24 @@ public class Writes(AccountsContext context) : IAccountWrites public async Task AddAsync(Account entity, CancellationToken ct = default) => (await context.Accounts.AddAsync(entity, ct).ConfigureAwait(false)).Entity; - public async Task ViewProductAsync(AccountId id, ProductId productId, CancellationToken ct = default) + public async Task ViewProductAsync(AccountId id, ProductId productId, DateTimeOffset viewedAt, CancellationToken ct = default) => await context.ViewedProducts.AddAsync( - entity: ViewedProduct.Create(id, productId), + entity: ViewedProduct.Create(id, productId, viewedAt), cancellationToken: ct ).ConfigureAwait(false); + public async Task UnviewProductAsync(AccountId id, ProductId productId, CancellationToken ct = default) + { + ViewedProduct? viewedProduct = await context.ViewedProducts + .FirstOrDefaultAsync(x => x.AccountId == id && x.ProductId == productId, ct) + .ConfigureAwait(false); + + if (viewedProduct is not null) + { + context.ViewedProducts.Remove(viewedProduct); + } + } + public void Remove(Account entity) => entity.Delete(); } diff --git a/src/Modules/Accounts/Persistence/ShadowEntities/ViewedProduct.cs b/src/Modules/Accounts/Persistence/ShadowEntities/ViewedProduct.cs deleted file mode 100644 index a4665cda6..000000000 --- a/src/Modules/Accounts/Persistence/ShadowEntities/ViewedProduct.cs +++ /dev/null @@ -1,16 +0,0 @@ -using CustomCADs.Modules.Accounts.Domain.Accounts; -using CustomCADs.Shared.Domain.TypedIds.Catalog; - -namespace CustomCADs.Modules.Accounts.Persistence.ShadowEntities; - -public class ViewedProduct -{ - private ViewedProduct() { } - - public AccountId AccountId { get; set; } - public ProductId ProductId { get; set; } - public Account Account { get; set; } = null!; - - public static ViewedProduct Create(AccountId id, ProductId productId) - => new() { AccountId = id, ProductId = productId }; -} diff --git a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Get/Single/GetGalleryProductEndpoint.cs b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Get/Single/GetGalleryProductEndpoint.cs index c6f8ec914..104e45906 100644 --- a/src/Modules/Catalog/API/Products/Endpoints/Gallery/Get/Single/GetGalleryProductEndpoint.cs +++ b/src/Modules/Catalog/API/Products/Endpoints/Gallery/Get/Single/GetGalleryProductEndpoint.cs @@ -17,10 +17,13 @@ public override void Configure() public override async Task HandleAsync(GetGalleryProductRequest req, CancellationToken ct) { + string? viewed = HttpContext.Request.Headers["X-Viewed-Product"]; + GalleryGetProductByIdDto product = await sender.SendQueryAsync( query: new GalleryGetProductByIdQuery( Id: ProductId.New(req.Id), - CallerId: User.AccountId + CallerId: User.AccountId, + Viewed: string.Equals(viewed, "true", StringComparison.CurrentCultureIgnoreCase) ), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedApplicationEvent.cs b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedApplicationEvent.cs index a0512c2b4..dfdfe9784 100644 --- a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedApplicationEvent.cs +++ b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedApplicationEvent.cs @@ -5,5 +5,6 @@ namespace CustomCADs.Modules.Catalog.Application.Products.Events.Application.Pro public record ProductViewedApplicationEvent( ProductId Id, - AccountId AccountId + AccountId AccountId, + DateTimeOffset ViewedAt ) : BaseApplicationEvent; diff --git a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs index 3661c0373..ac559af70 100644 --- a/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs +++ b/src/Modules/Catalog/Application/Products/Events/Application/ProductViewed/ProductViewedHandler.cs @@ -11,30 +11,23 @@ public class ProductViewedHandler(IProductReads reads, IUnitOfWork uow, IRequest { public async Task HandleAsync(ProductViewedApplicationEvent ae) { - Product product = await reads.SingleByIdAsync(ae.Id).ConfigureAwait(false) - ?? throw CustomNotFoundException.ById(ae.Id); - - string username = await sender.SendQueryAsync( - query: new GetUsernameByIdQuery(ae.AccountId) + bool userAlreadyViewed = await sender.SendQueryAsync( + query: new GetAccountViewedProductQuery(ae.AccountId, ae.Id) ).ConfigureAwait(false); + if (userAlreadyViewed) return; - AccountInfoDto account = await sender.SendQueryAsync( - query: new GetAccountInfoByUsernameQuery(username) + var account = await sender.SendQueryAsync( + query: new GetAccountInfoByUsernameQuery( + Username: await sender.SendQueryAsync( + query: new GetUsernameByIdQuery(ae.AccountId) + ).ConfigureAwait(false) + ) ).ConfigureAwait(false); - if (!account.TrackViewedProducts) - { - return; - } + if (!account.TrackViewedProducts) return; - bool userAlreadyViewed = await sender.SendQueryAsync( - query: new GetAccountViewedProductQuery(ae.AccountId, ae.Id) - ).ConfigureAwait(false); - - if (userAlreadyViewed) - { - return; - } + Product product = await reads.SingleByIdAsync(ae.Id).ConfigureAwait(false) + ?? throw CustomNotFoundException.ById(ae.Id); product.AddToViewCount(); await uow.SaveChangesAsync().ConfigureAwait(false); @@ -42,7 +35,8 @@ public async Task HandleAsync(ProductViewedApplicationEvent ae) await raiser.RaiseApplicationEventAsync( @event: new UserViewedProductApplicationEvent( Id: ae.Id, - AccountId: ae.AccountId + AccountId: ae.AccountId, + ViewedAt: ae.ViewedAt ) ).ConfigureAwait(false); } diff --git a/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandler.cs b/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandler.cs index d9dade8e8..44d1e294c 100644 --- a/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandler.cs +++ b/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandler.cs @@ -21,12 +21,13 @@ public async Task Handle(GalleryGetProductByIdQuery re throw CustomStatusException.ById(req.Id); } - if (!req.CallerId.IsEmpty()) + if (req.Viewed && !req.CallerId.IsEmpty()) { await raiser.RaiseApplicationEventAsync( @event: new ProductViewedApplicationEvent( Id: req.Id, - AccountId: req.CallerId + AccountId: req.CallerId, + ViewedAt: DateTimeOffset.UtcNow ) ).ConfigureAwait(false); } diff --git a/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdQuery.cs b/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdQuery.cs index 59a25f2f0..141d7861c 100644 --- a/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdQuery.cs +++ b/src/Modules/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdQuery.cs @@ -4,5 +4,6 @@ namespace CustomCADs.Modules.Catalog.Application.Products.Queries.Internal.Galle public sealed record GalleryGetProductByIdQuery( ProductId Id, - AccountId CallerId + AccountId CallerId, + bool Viewed ) : IQuery; diff --git a/src/Modules/Identity/API/Dtos/ViewedProductResponse.cs b/src/Modules/Identity/API/Dtos/ViewedProductResponse.cs new file mode 100644 index 000000000..5325c19f3 --- /dev/null +++ b/src/Modules/Identity/API/Dtos/ViewedProductResponse.cs @@ -0,0 +1,3 @@ +namespace CustomCADs.Modules.Identity.API.Dtos; + +public record ViewedProductResponse(Guid Id, DateTimeOffset ViewedAt); diff --git a/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductEndpoint.cs b/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductEndpoint.cs new file mode 100644 index 000000000..95c1eed1d --- /dev/null +++ b/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductEndpoint.cs @@ -0,0 +1,31 @@ +using CustomCADs.Shared.Application.UseCases.Accounts.Commands; +using CustomCADs.Shared.Domain.TypedIds.Catalog; + +namespace CustomCADs.Modules.Identity.API.Identity.Delete.ViewedProducts; + +public sealed class DeleteViewedProductEndpoint(IRequestSender sender) + : Endpoint +{ + public override void Configure() + { + Delete("viewed-product"); + Group(); + Description(x => x + .WithSummary("Remove Viewed Product") + .WithDescription("Remove a Viewed Product") + ); + } + + public override async Task HandleAsync(DeleteViewedProductRequest req, CancellationToken ct) + { + await sender.SendCommandAsync( + command: new DeleteViewedProductCommand( + ProductId: ProductId.New(req.ProductId), + CallerId: User.AccountId + ), + ct: ct + ).ConfigureAwait(false); + + await Send.NoContentAsync().ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductRequest.cs b/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductRequest.cs new file mode 100644 index 000000000..9f79a962f --- /dev/null +++ b/src/Modules/Identity/API/Identity/Delete/ViewedProducts/DeleteViewedProductRequest.cs @@ -0,0 +1,3 @@ +namespace CustomCADs.Modules.Identity.API.Identity.Delete.ViewedProducts; + +public sealed record DeleteViewedProductRequest(Guid ProductId); diff --git a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs index fb9ac3e6b..a2547b944 100644 --- a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs @@ -36,7 +36,7 @@ await JsonSerializer.SerializeAsync( firstName = user.FirstName, lastName = user.LastName, trackViewedProducts = user.TrackViewedProducts, - viewedProductIds = user.ViewedProductIds.Select(x => x.Value), + viewedProductIds = user.ViewedProducts.Select(x => new { id = x.Id.Value, x.ViewedAt }), }, options: new() { WriteIndented = true } ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs index 43cb5ec20..688636591 100644 --- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs +++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs @@ -1,4 +1,6 @@ +using CustomCADs.Modules.Identity.API.Dtos; using CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername; +using CustomCADs.Shared.Application.UseCases.Accounts.Queries; namespace CustomCADs.Modules.Identity.API.Identity.Get.MyAccount; @@ -13,6 +15,13 @@ public override MyAccountResponse FromEntity(GetUserByUsernameDto user) LastName: user.LastName, Email: user.Email.Value, TrackViewedProducts: user.TrackViewedProducts, - CreatedAt: user.CreatedAt + CreatedAt: user.CreatedAt, + ViewedProducts: [.. user.ViewedProducts.Select(ToResponse)] + ); + + private static ViewedProductResponse ToResponse(ViewedProductDto product) + => new( + Id: product.Id.Value, + ViewedAt: product.ViewedAt ); } diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs index 2d6ff7322..c4c245f4b 100644 --- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs +++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs @@ -1,3 +1,5 @@ +using CustomCADs.Modules.Identity.API.Dtos; + namespace CustomCADs.Modules.Identity.API.Identity.Get.MyAccount; public record MyAccountResponse( @@ -8,5 +10,6 @@ public record MyAccountResponse( string? LastName, string Email, bool TrackViewedProducts, - DateTimeOffset CreatedAt + DateTimeOffset CreatedAt, + ViewedProductResponse[] ViewedProducts ); diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs index f2bd49cd0..34aa9b286 100644 --- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs +++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs @@ -1,5 +1,5 @@ using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; -using CustomCADs.Shared.Domain.TypedIds.Catalog; +using CustomCADs.Shared.Application.UseCases.Accounts.Queries; namespace CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername; @@ -12,5 +12,5 @@ public sealed record GetUserByUsernameDto( bool TrackViewedProducts, Email Email, DateTimeOffset CreatedAt, - ProductId[] ViewedProductIds + ViewedProductDto[] ViewedProducts ); diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs index 41b5a2ff1..3ee1cfb20 100644 --- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs +++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs @@ -1,7 +1,6 @@ using CustomCADs.Shared.Application.Abstractions.Requests.Queries; using CustomCADs.Shared.Application.Abstractions.Requests.Sender; using CustomCADs.Shared.Application.UseCases.Accounts.Queries; -using CustomCADs.Shared.Domain.TypedIds.Catalog; namespace CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername; @@ -17,7 +16,7 @@ public async Task Handle(GetUserByUsernameQuery req, Cance ct: ct ).ConfigureAwait(false); - ProductId[] viewedProductIds = await sender.SendQueryAsync( + ViewedProductDto[] viewedProducts = await sender.SendQueryAsync( query: new GetAccountViewedProductsByUsernameQuery(user.Username), ct: ct ).ConfigureAwait(false); @@ -31,7 +30,7 @@ public async Task Handle(GetUserByUsernameQuery req, Cance CreatedAt: info.CreatedAt, FirstName: info.FirstName, LastName: info.LastName, - ViewedProductIds: viewedProductIds + ViewedProducts: viewedProducts ); } } diff --git a/src/Shared/Application/Events/Catalog/UserViewedProductApplicationEvent.cs b/src/Shared/Application/Events/Catalog/UserViewedProductApplicationEvent.cs index 0b5712782..02485e742 100644 --- a/src/Shared/Application/Events/Catalog/UserViewedProductApplicationEvent.cs +++ b/src/Shared/Application/Events/Catalog/UserViewedProductApplicationEvent.cs @@ -2,5 +2,6 @@ public record UserViewedProductApplicationEvent( AccountId AccountId, - ProductId Id + ProductId Id, + DateTimeOffset ViewedAt ) : BaseApplicationEvent; diff --git a/src/Shared/Application/UseCases/Accounts/Commands/DeleteViewedProductCommand.cs b/src/Shared/Application/UseCases/Accounts/Commands/DeleteViewedProductCommand.cs new file mode 100644 index 000000000..604a0c440 --- /dev/null +++ b/src/Shared/Application/UseCases/Accounts/Commands/DeleteViewedProductCommand.cs @@ -0,0 +1,6 @@ +namespace CustomCADs.Shared.Application.UseCases.Accounts.Commands; + +public record DeleteViewedProductCommand( + ProductId ProductId, + AccountId CallerId +) : ICommand; diff --git a/src/Shared/Application/UseCases/Accounts/Queries/GetAccountViewedProductsByUsernameQuery.cs b/src/Shared/Application/UseCases/Accounts/Queries/GetAccountViewedProductsByUsernameQuery.cs index 83804c413..5a61180df 100644 --- a/src/Shared/Application/UseCases/Accounts/Queries/GetAccountViewedProductsByUsernameQuery.cs +++ b/src/Shared/Application/UseCases/Accounts/Queries/GetAccountViewedProductsByUsernameQuery.cs @@ -2,4 +2,9 @@ namespace CustomCADs.Shared.Application.UseCases.Accounts.Queries; public sealed record GetAccountViewedProductsByUsernameQuery( string Username -) : IQuery; +) : IQuery; + +public sealed record ViewedProductDto( + ProductId Id, + DateTimeOffset ViewedAt +); diff --git a/tests/UnitTests/Accounts/Application/Accounts/Events/Application/UserViewedProductHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Events/Application/UserViewedProductHandlerUnitTests.cs index 37f4c79b5..2745f7543 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Events/Application/UserViewedProductHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Events/Application/UserViewedProductHandlerUnitTests.cs @@ -15,6 +15,7 @@ public class UserViewedProductHandlerUnitTests : AccountsBaseUnitTests private static readonly AccountId id = AccountId.New(); private static readonly ProductId productId = ProductId.New(); + private static readonly DateTimeOffset viewedAt = DateTimeOffset.UtcNow; public UserViewedProductHandlerUnitTests() { @@ -25,13 +26,13 @@ public UserViewedProductHandlerUnitTests() public async Task Handle_ShouldPersistToDatabase() { // Arrange - UserViewedProductApplicationEvent ie = new(id, productId); + UserViewedProductApplicationEvent ie = new(id, productId, viewedAt); // Act await handler.HandleAsync(ie); // Assert - writes.Verify(x => x.ViewProductAsync(id, productId, ct), Times.Once()); + writes.Verify(x => x.ViewProductAsync(id, productId, viewedAt, ct), Times.Once()); uow.Verify(x => x.SaveChangesAsync(ct), Times.Once()); } } diff --git a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandlerUnitTests.cs similarity index 96% rename from tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandlerUnitTests.cs rename to tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandlerUnitTests.cs index 463c07072..762d49449 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductHandlerUnitTests.cs @@ -1,9 +1,9 @@ -using CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProduct; +using CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProducts; using CustomCADs.Modules.Accounts.Domain.Repositories.Reads; using CustomCADs.Shared.Application.UseCases.Accounts.Queries; using CustomCADs.Shared.Domain.TypedIds.Catalog; -namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Queries.Shared.ViewedProduct; +namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Queries.Shared.ViewedProducts; using static AccountsData; diff --git a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandlerUnitTests.cs similarity index 78% rename from tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandlerUnitTests.cs rename to tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandlerUnitTests.cs index 1211a6270..e1d97783a 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProduct/GetAccountViewedProductsByUsernameHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Queries/Shared/ViewedProducts/GetAccountViewedProductsByUsernameHandlerUnitTests.cs @@ -1,9 +1,9 @@ -using CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProduct; +using CustomCADs.Modules.Accounts.Application.Accounts.Queries.Shared.ViewedProducts; +using CustomCADs.Modules.Accounts.Domain.Accounts.Entities; using CustomCADs.Modules.Accounts.Domain.Repositories.Reads; using CustomCADs.Shared.Application.UseCases.Accounts.Queries; -using CustomCADs.Shared.Domain.TypedIds.Catalog; -namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Queries.Shared.ViewedProduct; +namespace CustomCADs.UnitTests.Accounts.Application.Accounts.Queries.Shared.ViewedProducts; using static AccountsData; @@ -12,7 +12,7 @@ public class GetAccountViewedProductsByUsernameHandlerUnitTests : AccountsBaseUn private readonly GetAccountViewedProductsByUsernameHandler handler; private readonly Mock reads = new(); - private static readonly ProductId[] expected = []; + private static readonly ViewedProduct[] expected = []; public GetAccountViewedProductsByUsernameHandlerUnitTests() { @@ -41,9 +41,9 @@ public async Task Handle_ShouldReturnResult() GetAccountViewedProductsByUsernameQuery query = new(ValidUsername); // Act - ProductId[] ids = await handler.Handle(query, ct); + ViewedProductDto[] products = await handler.Handle(query, ct); // Assert - Assert.Equal(expected, ids); + Assert.Equal(expected.Select(x => x.ProductId), products.Select(x => x.Id)); } } diff --git a/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs b/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs index 60d83f91a..c3c5f4636 100644 --- a/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs +++ b/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs @@ -29,6 +29,7 @@ public class ProductViewedHandlerUnitTests : ProductsBaseUnitTests LastName: null ); private readonly Product product = CreateProduct(); + private static readonly DateTimeOffset viewedAt = DateTimeOffset.UtcNow; public ProductViewedHandlerUnitTests() { @@ -57,7 +58,7 @@ public ProductViewedHandlerUnitTests() public async Task Handle_ShouldQueryDatabase() { // Arrange - ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId); + ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId, viewedAt); // Act await handler.HandleAsync(ae); @@ -70,7 +71,7 @@ public async Task Handle_ShouldQueryDatabase() public async Task Handle_ShouldPersistToDatabase() { // Arrange - ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId); + ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId, viewedAt); // Act await handler.HandleAsync(ae); @@ -83,7 +84,7 @@ public async Task Handle_ShouldPersistToDatabase() public async Task Handle_ShouldSendRequests() { // Arrange - ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId); + ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId, viewedAt); // Act await handler.HandleAsync(ae); @@ -107,7 +108,7 @@ public async Task Handle_ShouldSendRequests() public async Task Handle_ShouldRaiseEvents() { // Arrange - ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId); + ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId, viewedAt); // Act await handler.HandleAsync(ae); @@ -122,7 +123,7 @@ public async Task Handle_ShouldRaiseEvents() public async Task Handle_ShouldPopulateProperties() { // Arrange - ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId); + ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId, viewedAt); // Act await handler.HandleAsync(ae); @@ -139,7 +140,7 @@ public async Task Handle_ShouldReturnEarly_WhenUserDoesNotTrackViewedProducts() It.Is(x => x.Username == Username), ct )).ReturnsAsync(info with { TrackViewedProducts = false }); - ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId); + ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId, viewedAt); // Act await handler.HandleAsync(ae); @@ -159,7 +160,7 @@ public async Task Handle_ShouldReturnEarly_WhenUserAlreadyViewedProduct() It.Is(x => x.Id == ValidCreatorId && x.ProductId == ValidId), ct )).ReturnsAsync(true); - ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId); + ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId, viewedAt); // Act await handler.HandleAsync(ae); @@ -178,7 +179,7 @@ public async Task Handle_ShouldThrowException_WhenProductNotFound() reads.Setup(x => x.SingleByIdAsync(ValidId, true, ct)) .ReturnsAsync(null as Product); - ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId); + ProductViewedApplicationEvent ae = new(ValidId, ValidCreatorId, viewedAt); // Assert await Assert.ThrowsAsync>( diff --git a/tests/UnitTests/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandlerUnitTests.cs b/tests/UnitTests/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandlerUnitTests.cs index 913ab487b..7f01efda7 100644 --- a/tests/UnitTests/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandlerUnitTests.cs +++ b/tests/UnitTests/Catalog/Application/Products/Queries/Internal/Gallery/GetById/GalleryGetProductByIdHandlerUnitTests.cs @@ -21,6 +21,7 @@ public class GalleryGetProductByIdHandlerUnitTests : ProductsBaseUnitTests private readonly Mock raiser = new(); private readonly Product product = CreateProductWithId(id: ValidId); + private static readonly DateTimeOffset viewedAt = DateTimeOffset.UtcNow; public GalleryGetProductByIdHandlerUnitTests() { @@ -37,7 +38,7 @@ public GalleryGetProductByIdHandlerUnitTests() public async Task Handle_ShouldQueryDatabase() { // Arrange - GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId); + GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId, Viewed: false); // Act await handler.Handle(query, ct); @@ -50,7 +51,7 @@ public async Task Handle_ShouldQueryDatabase() public async Task Handle_ShouldSendRequests() { // Arrange - GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId); + GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId, Viewed: false); // Act await handler.Handle(query, ct); @@ -66,26 +67,17 @@ public async Task Handle_ShouldSendRequests() ), Times.Once()); } - [Fact] - public async Task Handle_ShouldRaiseEvents_WhenAccountIdEmpty() + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task Handle_ShouldRaiseEvents(bool authenticatedUser, bool viewed) { - // Arrange - GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId); + AccountId creatorId = authenticatedUser ? ValidCreatorId : AccountId.New(Guid.Empty); - // Act - await handler.Handle(query, ct); - - // Assert - raiser.Verify(x => x.RaiseApplicationEventAsync( - It.Is(x => x.Id == product.Id) - ), Times.Once()); - } - - [Fact] - public async Task Handle_ShouldNotRaiseEvents_WhenAccountIdEmpty() - { // Arrange - GalleryGetProductByIdQuery query = new(ValidId, AccountId.New(Guid.Empty)); + GalleryGetProductByIdQuery query = new(ValidId, creatorId, Viewed: viewed); // Act await handler.Handle(query, ct); @@ -93,14 +85,14 @@ public async Task Handle_ShouldNotRaiseEvents_WhenAccountIdEmpty() // Assert raiser.Verify(x => x.RaiseApplicationEventAsync( It.Is(x => x.Id == product.Id) - ), Times.Never()); + ), Times.Exactly(authenticatedUser && viewed ? 1 : 0)); } [Fact] public async Task Handle_ShouldReturnResult() { // Arrange - GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId); + GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId, Viewed: false); // Act var result = await handler.Handle(query, ct); @@ -120,7 +112,7 @@ public async Task Handle_ShouldThrowException_WhenStatusIsNotValid() { // Arrange product.Report(ValidDesignerId); - GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId); + GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId, Viewed: false); // Assert await Assert.ThrowsAsync>( @@ -135,7 +127,7 @@ public async Task Handle_ShouldThrowException_WhenProductNotFound() // Arrange reads.Setup(x => x.SingleByIdAsync(ValidId, false, ct)) .ReturnsAsync(null as Product); - GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId); + GalleryGetProductByIdQuery query = new(ValidId, ValidCreatorId, Viewed: false); // Assert await Assert.ThrowsAsync>( From da82f3105151d140ac4e0c6901379d339453160c Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Mon, 11 May 2026 23:16:20 +0300 Subject: [PATCH 15/22] Implemented RefreshToken Fingerprint feature --- Directory.Packages.props | 1 + .../Identity/API/Dtos/FingerprintResponse.cs | 9 + .../DeleteFingerprintsEndpoint.cs | 33 ++ .../DeleteViewedProductRequest.cs | 3 + .../Get/DownloadInfo/DownloadInfoEndpoint.cs | 5 +- .../Get/MyAccount/MyAccountEndpoint.cs | 2 +- .../Identity/Get/MyAccount/MyAccountMapper.cs | 13 +- .../Get/MyAccount/MyAccountResponse.cs | 3 +- .../API/Identity/Post/Login/LoginEndpoint.cs | 8 +- .../Post/RefreshToken/RefreshTokenEndpoint.cs | 8 +- .../Post/VerifyEmail/ConfirmEmailEndpoint.cs | 8 +- .../Identity/API/SSODependencyInjection.cs | 5 +- .../Contracts/IFingerprintService.cs | 8 + .../Application/Contracts/IUserService.cs | 1 + .../Fingerprints/DeleteFingerprintCommand.cs | 9 + .../Fingerprints/DeleteFingerprintHandler.cs | 28 + .../Internal/Login/LoginUserCommand.cs | 4 +- .../Internal/Login/LoginUserHandler.cs | 2 +- .../Internal/Refresh/RefreshUserCommand.cs | 4 +- .../Internal/Refresh/RefreshUserHandler.cs | 2 +- .../SSO/Register/SingleSignOnUserCommand.cs | 4 +- .../SSO/Register/SingleSignOnUserHandler.cs | 2 +- .../VerifyEmail/VerifyUserEmailCommand.cs | 4 +- .../VerifyEmail/VerifyUserEmailHandler.cs | 2 +- .../Application/Users/Dtos/FingerprintDto.cs | 9 + .../GetByUsername/GetUserByUsernameDto.cs | 4 +- .../GetByUsername/GetUserByUsernameHandler.cs | 17 +- .../GetByUsername/GetUserByUsernameQuery.cs | 3 +- .../Domain/Users/Entities/RefreshToken.cs | 20 +- src/Modules/Identity/Domain/Users/User.cs | 4 +- .../Domain/Users/ValueObjects/Fingerprint.cs | 3 + .../Infrastructure/DependencyInjection.cs | 4 + .../DeviceDetectorFingerprintService.cs | 49 ++ .../AppRefreshTokens/Configurations.cs | 1 + .../AppRefreshTokens/Utilities.cs | 14 +- .../Infrastructure/Identity/Mapper.cs | 27 +- ...dded_RefreshToken_Fingerprints.Designer.cs | 498 ++++++++++++++++++ ...6185244_Added_RefreshToken_Fingerprints.cs | 42 ++ .../IdentityContextModelSnapshot.cs | 15 + .../Identity/Services/AppUserService.Reads.cs | 17 +- .../Services/AppUserService.Writes.cs | 12 + .../ShadowEntities/AppRefreshToken.cs | 6 +- .../Modules.Identity.Infrastructure.csproj | 1 + src/Presentation/Program.cs | 1 + src/Shared/API/Extensions/HttpExtensions.cs | 6 + 45 files changed, 860 insertions(+), 61 deletions(-) create mode 100644 src/Modules/Identity/API/Dtos/FingerprintResponse.cs create mode 100644 src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteFingerprintsEndpoint.cs create mode 100644 src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteViewedProductRequest.cs create mode 100644 src/Modules/Identity/Application/Contracts/IFingerprintService.cs create mode 100644 src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintCommand.cs create mode 100644 src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintHandler.cs create mode 100644 src/Modules/Identity/Application/Users/Dtos/FingerprintDto.cs create mode 100644 src/Modules/Identity/Domain/Users/ValueObjects/Fingerprint.cs create mode 100644 src/Modules/Identity/Infrastructure/Fingerprints/DeviceDetectorFingerprintService.cs create mode 100644 src/Modules/Identity/Infrastructure/Identity/Migrations/20260506185244_Added_RefreshToken_Fingerprints.Designer.cs create mode 100644 src/Modules/Identity/Infrastructure/Identity/Migrations/20260506185244_Added_RefreshToken_Fingerprints.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 0d8c4b53e..ce530bfea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + diff --git a/src/Modules/Identity/API/Dtos/FingerprintResponse.cs b/src/Modules/Identity/API/Dtos/FingerprintResponse.cs new file mode 100644 index 000000000..6172672bd --- /dev/null +++ b/src/Modules/Identity/API/Dtos/FingerprintResponse.cs @@ -0,0 +1,9 @@ +namespace CustomCADs.Modules.Identity.API.Dtos; + +public record FingerprintResponse( + Guid Id, + string Device, + string? Location, + bool DeleteAllowed, + DateTimeOffset IssuedAt +); diff --git a/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteFingerprintsEndpoint.cs b/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteFingerprintsEndpoint.cs new file mode 100644 index 000000000..680e4362c --- /dev/null +++ b/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteFingerprintsEndpoint.cs @@ -0,0 +1,33 @@ +using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete.Fingerprints; +using CustomCADs.Shared.Domain.TypedIds.Identity; + +namespace CustomCADs.Modules.Identity.API.Identity.Delete.Fingerprints; + +public sealed class DeleteFingerprintsEndpoint(IRequestSender sender) + : Endpoint +{ + public override void Configure() + { + Delete("fingerprint"); + Group(); + Description(x => x + .WithSummary("Remove Fingerprint") + .WithDescription("Remove a Fingerprint") + ); + } + + public override async Task HandleAsync(DeleteFingerprintsRequest req, CancellationToken ct) + { + await sender.SendCommandAsync( + command: new DeleteFingerprintCommand( + RefreshTokenId: RefreshTokenId.New(req.RefreshTokenId), + CurrentRefreshToken: HttpContext.RefreshTokenCookie, + CallerId: User.AccountId + ), + ct: ct + ).ConfigureAwait(false); + + await Send.NoContentAsync().ConfigureAwait(false); + } +} + diff --git a/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteViewedProductRequest.cs b/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteViewedProductRequest.cs new file mode 100644 index 000000000..9f6f22bc9 --- /dev/null +++ b/src/Modules/Identity/API/Identity/Delete/Fingerprints/DeleteViewedProductRequest.cs @@ -0,0 +1,3 @@ +namespace CustomCADs.Modules.Identity.API.Identity.Delete.Fingerprints; + +public sealed record DeleteFingerprintsRequest(Guid RefreshTokenId); diff --git a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs index a2547b944..1582bb8fd 100644 --- a/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/DownloadInfo/DownloadInfoEndpoint.cs @@ -19,7 +19,7 @@ public override void Configure() public override async Task HandleAsync(CancellationToken ct) { GetUserByUsernameDto user = await sender.SendQueryAsync( - query: new GetUserByUsernameQuery(User.AccountId), + query: new GetUserByUsernameQuery(User.AccountId, HttpContext.RefreshTokenCookie), ct: ct ).ConfigureAwait(false); @@ -36,7 +36,8 @@ await JsonSerializer.SerializeAsync( firstName = user.FirstName, lastName = user.LastName, trackViewedProducts = user.TrackViewedProducts, - viewedProductIds = user.ViewedProducts.Select(x => new { id = x.Id.Value, x.ViewedAt }), + fingerprints = user.Fingerprints.Select(x => new { id = x.Id.Value, device = x.Device, location = x.Location, issuedAt = x.IssuedAt }), + viewedProducts = user.ViewedProducts.Select(x => new { id = x.Id.Value, viewedAt = x.ViewedAt }), }, options: new() { WriteIndented = true } ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs index 3b6c94b7e..a55d5ddfb 100644 --- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountEndpoint.cs @@ -18,7 +18,7 @@ public override void Configure() public override async Task HandleAsync(CancellationToken ct) { GetUserByUsernameDto user = await sender.SendQueryAsync( - query: new GetUserByUsernameQuery(User.AccountId), + query: new GetUserByUsernameQuery(User.AccountId, HttpContext.RefreshTokenCookie), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs index 688636591..28a4eac76 100644 --- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs +++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountMapper.cs @@ -1,4 +1,5 @@ using CustomCADs.Modules.Identity.API.Dtos; +using CustomCADs.Modules.Identity.Application.Users.Dtos; using CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername; using CustomCADs.Shared.Application.UseCases.Accounts.Queries; @@ -16,7 +17,8 @@ public override MyAccountResponse FromEntity(GetUserByUsernameDto user) Email: user.Email.Value, TrackViewedProducts: user.TrackViewedProducts, CreatedAt: user.CreatedAt, - ViewedProducts: [.. user.ViewedProducts.Select(ToResponse)] + ViewedProducts: [.. user.ViewedProducts.Select(ToResponse)], + Fingerprints: [.. user.Fingerprints.Select(ToResponse)] ); private static ViewedProductResponse ToResponse(ViewedProductDto product) @@ -24,4 +26,13 @@ private static ViewedProductResponse ToResponse(ViewedProductDto product) Id: product.Id.Value, ViewedAt: product.ViewedAt ); + + private static FingerprintResponse ToResponse(FingerprintDto fingerprint) + => new( + Id: fingerprint.Id.Value, + Device: fingerprint.Device, + Location: fingerprint.Location, + DeleteAllowed: fingerprint.DeleteAllowed, + IssuedAt: fingerprint.IssuedAt + ); } diff --git a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs index c4c245f4b..fcc974ab3 100644 --- a/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs +++ b/src/Modules/Identity/API/Identity/Get/MyAccount/MyAccountResponse.cs @@ -11,5 +11,6 @@ public record MyAccountResponse( string Email, bool TrackViewedProducts, DateTimeOffset CreatedAt, - ViewedProductResponse[] ViewedProducts + ViewedProductResponse[] ViewedProducts, + FingerprintResponse[] Fingerprints ); diff --git a/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs b/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs index 0f85a0b4d..33558dd2c 100644 --- a/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/Login/LoginEndpoint.cs @@ -1,11 +1,12 @@ -using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Login; +using CustomCADs.Modules.Identity.Application.Contracts; +using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Login; using CustomCADs.Modules.Identity.Application.Users.Dtos; using CustomCADs.Shared.API.Attributes; using Microsoft.Extensions.Options; namespace CustomCADs.Modules.Identity.API.Identity.Post.Login; -public sealed class LoginEndpoint(IRequestSender sender, IOptions settings) +public sealed class LoginEndpoint(IRequestSender sender, IFingerprintService fingerprintService, IOptions settings) : Endpoint { public override void Configure() @@ -26,7 +27,8 @@ public override async Task HandleAsync(LoginRequest req, CancellationToken ct) command: new LoginUserCommand( Username: req.Username, Password: req.Password, - LongerExpireTime: req.RememberMe ?? false + LongerExpireTime: req.RememberMe ?? false, + Fingerprint: fingerprintService.GetFingerprint(HttpContext.Request.HeadersDictionary) ), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs b/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs index 27e6c137d..bfe25e195 100644 --- a/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/RefreshToken/RefreshTokenEndpoint.cs @@ -1,11 +1,12 @@ -using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Refresh; +using CustomCADs.Modules.Identity.Application.Contracts; +using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Refresh; using CustomCADs.Modules.Identity.Application.Users.Dtos; using CustomCADs.Shared.API.Attributes; using Microsoft.Extensions.Options; namespace CustomCADs.Modules.Identity.API.Identity.Post.RefreshToken; -public sealed class RefreshTokenEndpoint(IRequestSender sender, IOptions settings) +public sealed class RefreshTokenEndpoint(IRequestSender sender, IFingerprintService fingerprintService, IOptions settings) : EndpointWithoutRequest { public override void Configure() @@ -24,7 +25,8 @@ public override async Task HandleAsync(CancellationToken ct) { TokensDto tokens = await sender.SendCommandAsync( command: new RefreshUserCommand( - Token: HttpContext.RefreshTokenCookie + Token: HttpContext.RefreshTokenCookie, + Fingerprint: fingerprintService.GetFingerprint(HttpContext.Request.HeadersDictionary) ), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs b/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs index 3190dd61a..bf66cb17e 100644 --- a/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Post/VerifyEmail/ConfirmEmailEndpoint.cs @@ -1,11 +1,12 @@ -using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.VerifyEmail; +using CustomCADs.Modules.Identity.Application.Contracts; +using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.VerifyEmail; using CustomCADs.Modules.Identity.Application.Users.Dtos; using CustomCADs.Shared.API.Attributes; using Microsoft.Extensions.Options; namespace CustomCADs.Modules.Identity.API.Identity.Post.VerifyEmail; -public sealed class ConfirmEmailEndpoint(IRequestSender sender, IOptions settings) +public sealed class ConfirmEmailEndpoint(IRequestSender sender, IFingerprintService fingerprintService, IOptions settings) : Endpoint { public override void Configure() @@ -25,7 +26,8 @@ public override async Task HandleAsync(ConfirmEmailRequest req, CancellationToke TokensDto tokens = await sender.SendCommandAsync( command: new VerifyUserEmailCommand( Username: req.Username, - Token: req.Token.Replace(' ', '+') + Token: req.Token.Replace(' ', '+'), + Fingerprint: fingerprintService.GetFingerprint(HttpContext.Request.HeadersDictionary) ), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/SSODependencyInjection.cs b/src/Modules/Identity/API/SSODependencyInjection.cs index bd926b153..fd1779582 100644 --- a/src/Modules/Identity/API/SSODependencyInjection.cs +++ b/src/Modules/Identity/API/SSODependencyInjection.cs @@ -1,4 +1,5 @@ using CustomCADs.Modules.Identity.API; +using CustomCADs.Modules.Identity.Application.Contracts; using CustomCADs.Modules.Identity.Application.Users.Commands.Internal.SSO.Register; using CustomCADs.Modules.Identity.Application.Users.Dtos; using Microsoft.AspNetCore.Authentication; @@ -77,6 +78,7 @@ out string? lastName ctx.Properties?.Items.TryGetValue("role", out role); IRequestSender sender = sp.GetRequiredService(); + IFingerprintService fingerprint = sp.GetRequiredService(); CookieSettings cookie = sp.GetRequiredService>().Value; ctx.HttpContext.SaveAllCookies( @@ -89,7 +91,8 @@ out string? lastName LastName: lastName, Username: username, Email: email, - Provider: provider + Provider: provider, + Fingerprint: fingerprint.GetFingerprint(ctx.Request.HeadersDictionary) ), ct: ct ).ConfigureAwait(false) diff --git a/src/Modules/Identity/Application/Contracts/IFingerprintService.cs b/src/Modules/Identity/Application/Contracts/IFingerprintService.cs new file mode 100644 index 000000000..bf660d8eb --- /dev/null +++ b/src/Modules/Identity/Application/Contracts/IFingerprintService.cs @@ -0,0 +1,8 @@ +namespace CustomCADs.Modules.Identity.Application.Contracts; + +using Domain.Users.ValueObjects; + +public interface IFingerprintService +{ + Fingerprint GetFingerprint(Dictionary headers); +} diff --git a/src/Modules/Identity/Application/Contracts/IUserService.cs b/src/Modules/Identity/Application/Contracts/IUserService.cs index 33db7979f..a90a37883 100644 --- a/src/Modules/Identity/Application/Contracts/IUserService.cs +++ b/src/Modules/Identity/Application/Contracts/IUserService.cs @@ -30,6 +30,7 @@ public interface IUserService Task CheckPasswordAsync(string username, string password); Task UpdateUsernameAsync(UserId id, string username); Task SaveRefreshTokensAsync(User user); + Task RevokeRefreshTokenAsync(RefreshTokenId refreshTokenId); Task RevokeRefreshTokenAsync(string token); #endregion diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintCommand.cs new file mode 100644 index 000000000..9fbe80cc8 --- /dev/null +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintCommand.cs @@ -0,0 +1,9 @@ +using CustomCADs.Shared.Domain.TypedIds.Accounts; + +namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete.Fingerprints; + +public record DeleteFingerprintCommand( + RefreshTokenId RefreshTokenId, + string? CurrentRefreshToken, + AccountId CallerId +) : ICommand; diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintHandler.cs new file mode 100644 index 000000000..ded224345 --- /dev/null +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/Fingerprints/DeleteFingerprintHandler.cs @@ -0,0 +1,28 @@ +using CustomCADs.Modules.Identity.Application.Extensions; +using CustomCADs.Modules.Identity.Domain.Users.Entities; +using CustomCADs.Shared.Application.Exceptions; + +namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete.Fingerprints; + +public class DeleteFingerprintHandler(IUserService service) : ICommandHandler +{ + public async Task Handle(DeleteFingerprintCommand req, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(req.CurrentRefreshToken)) + { + throw CustomAuthorizationException.NoRefreshToken(); + } + + (User user, RefreshToken refreshToken) = await service.GetByRefreshTokenAsync(req.CurrentRefreshToken).ConfigureAwait(false); + if (req.CallerId != user.AccountId) + { + throw CustomAuthorizationException.ById(user.AccountId); + } + + if (refreshToken.Id == req.RefreshTokenId) + { + throw new CustomException("Cannot delete User's current RefreshToken"); + } + await service.RevokeRefreshTokenAsync(req.RefreshTokenId).ConfigureAwait(false); + } +} diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserCommand.cs index 7fa489722..f4e32773b 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserCommand.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserCommand.cs @@ -1,9 +1,11 @@ using CustomCADs.Modules.Identity.Application.Users.Dtos; +using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Login; public sealed record LoginUserCommand( string Username, string Password, - bool LongerExpireTime + bool LongerExpireTime, + Fingerprint Fingerprint ) : ICommand; diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserHandler.cs index 92ede72b5..93b3b8267 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Login/LoginUserHandler.cs @@ -38,7 +38,7 @@ public async Task Handle(LoginUserCommand req, CancellationToken ct) } RefreshToken rt = tokenService.IssueRefreshToken( - createRefreshToken: (token) => user.AddRefreshToken(token, longerSession: false) + createRefreshToken: (token) => user.AddRefreshToken(token, req.Fingerprint, longerSession: false) ); await service.SaveRefreshTokensAsync(user).ConfigureAwait(false); diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserCommand.cs index d48057538..b57d1bb9e 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserCommand.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserCommand.cs @@ -1,6 +1,8 @@ using CustomCADs.Modules.Identity.Application.Users.Dtos; +using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Refresh; public sealed record RefreshUserCommand( - string? Token + string? Token, + Fingerprint Fingerprint ) : ICommand; diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs index 0e5bcc32a..70bb7d74b 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs @@ -23,7 +23,7 @@ public async Task Handle(RefreshUserCommand req, CancellationToken ct } RefreshToken rt = tokenService.IssueRefreshToken( - createRefreshToken: (token) => User.AddRefreshToken(token, longerSession: false) + createRefreshToken: (token) => User.AddRefreshToken(token, req.Fingerprint, longerSession: false) ); await service.SaveRefreshTokensAsync(User).ConfigureAwait(false); diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs index caafd76b3..84946191f 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserCommand.cs @@ -1,4 +1,5 @@ using CustomCADs.Modules.Identity.Application.Users.Dtos; +using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.SSO.Register; @@ -8,5 +9,6 @@ public sealed record SingleSignOnUserCommand( string? LastName, string Username, string Email, - string Provider + string Provider, + Fingerprint Fingerprint ) : ICommand; diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs index da98ba06a..8d395f757 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/SSO/Register/SingleSignOnUserHandler.cs @@ -18,7 +18,7 @@ public async Task Handle(SingleSignOnUserCommand req, CancellationTok User user = await GetUserAsync(req, ct).ConfigureAwait(false); RefreshToken rt = tokenService.IssueRefreshToken( - createRefreshToken: (token) => user.AddRefreshToken(token, longerSession: false) + createRefreshToken: (token) => user.AddRefreshToken(token, req.Fingerprint, longerSession: false) ); await service.SaveRefreshTokensAsync(user).ConfigureAwait(false); diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailCommand.cs index 2718a7700..d5e3a5f7e 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailCommand.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailCommand.cs @@ -1,8 +1,10 @@ using CustomCADs.Modules.Identity.Application.Users.Dtos; +using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.VerifyEmail; public sealed record VerifyUserEmailCommand( string Username, - string Token + string Token, + Fingerprint Fingerprint ) : ICommand; diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandler.cs index e2d718ff4..49888f9b0 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandler.cs @@ -20,7 +20,7 @@ public async Task Handle(VerifyUserEmailCommand req, CancellationToke await service.ConfirmEmailAsync(req.Username, req.Token).ConfigureAwait(false); RefreshToken rt = tokenService.IssueRefreshToken( - createRefreshToken: (token) => user.AddRefreshToken(token, longerSession: false) + createRefreshToken: (token) => user.AddRefreshToken(token, req.Fingerprint, longerSession: false) ); await service.SaveRefreshTokensAsync(user).ConfigureAwait(false); diff --git a/src/Modules/Identity/Application/Users/Dtos/FingerprintDto.cs b/src/Modules/Identity/Application/Users/Dtos/FingerprintDto.cs new file mode 100644 index 000000000..ddf3f0dfb --- /dev/null +++ b/src/Modules/Identity/Application/Users/Dtos/FingerprintDto.cs @@ -0,0 +1,9 @@ +namespace CustomCADs.Modules.Identity.Application.Users.Dtos; + +public record FingerprintDto( + RefreshTokenId Id, + string Device, + string? Location, + bool DeleteAllowed, + DateTimeOffset IssuedAt +); diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs index 34aa9b286..10a1a2cce 100644 --- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs +++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameDto.cs @@ -1,3 +1,4 @@ +using CustomCADs.Modules.Identity.Application.Users.Dtos; using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; using CustomCADs.Shared.Application.UseCases.Accounts.Queries; @@ -12,5 +13,6 @@ public sealed record GetUserByUsernameDto( bool TrackViewedProducts, Email Email, DateTimeOffset CreatedAt, - ViewedProductDto[] ViewedProducts + ViewedProductDto[] ViewedProducts, + FingerprintDto[] Fingerprints ); diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs index 3ee1cfb20..0b5b72a83 100644 --- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs +++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandler.cs @@ -1,3 +1,5 @@ +using CustomCADs.Modules.Identity.Application.Users.Dtos; +using CustomCADs.Modules.Identity.Domain.Users.Entities; using CustomCADs.Shared.Application.Abstractions.Requests.Queries; using CustomCADs.Shared.Application.Abstractions.Requests.Sender; using CustomCADs.Shared.Application.UseCases.Accounts.Queries; @@ -30,7 +32,20 @@ public async Task Handle(GetUserByUsernameQuery req, Cance CreatedAt: info.CreatedAt, FirstName: info.FirstName, LastName: info.LastName, - ViewedProducts: viewedProducts + ViewedProducts: viewedProducts, + Fingerprints: [.. user.RefreshTokens + .Select(x => ToFingerprintDto(x, x.Value == req.RefreshToken)) + .OrderByDescending(x => x.IssuedAt) + ] ); } + + private static FingerprintDto ToFingerprintDto(RefreshToken refreshToken, bool isCurrent) + => new( + Id: refreshToken.Id, + Device: refreshToken.Fingerprint.Device, + Location: refreshToken.Fingerprint.Location, + IssuedAt: refreshToken.IssuedAt, + DeleteAllowed: !isCurrent + ); } diff --git a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs index e07896816..21456faec 100644 --- a/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs +++ b/src/Modules/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameQuery.cs @@ -4,5 +4,6 @@ namespace CustomCADs.Modules.Identity.Application.Users.Queries.Internal.GetByUsername; public sealed record GetUserByUsernameQuery( - AccountId Id + AccountId Id, + string? RefreshToken ) : IQuery; diff --git a/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs b/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs index 20f09b68d..2f83d53bb 100644 --- a/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs +++ b/src/Modules/Identity/Domain/Users/Entities/RefreshToken.cs @@ -2,23 +2,26 @@ namespace CustomCADs.Modules.Identity.Domain.Users.Entities; +using ValueObjects; using static Constants.Tokens; public class RefreshToken : BaseEntity { private RefreshToken() { } - private RefreshToken(string value, UserId userId, bool longerSession) + private RefreshToken(string value, Fingerprint fingerprint, UserId userId, bool longerSession) { Value = value; + Fingerprint = fingerprint; UserId = userId; IssuedAt = DateTimeOffset.UtcNow; ExpiresAt = IssuedAt.AddDays( longerSession ? LongerRtDurationInDays : RtDurationInDays ); } - private RefreshToken(string value, UserId userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt) + private RefreshToken(string value, Fingerprint fingerprint, UserId userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt) { Value = value; + Fingerprint = fingerprint; UserId = userId; IssuedAt = issuedAt; ExpiresAt = expiresAt; @@ -28,19 +31,20 @@ private RefreshToken(string value, UserId userId, DateTimeOffset issuedAt, DateT public DateTimeOffset IssuedAt { get; init; } public DateTimeOffset ExpiresAt { get; init; } public string Value { get; private set; } = string.Empty; + public Fingerprint Fingerprint { get; private set; } = new(); public UserId UserId { get; private set; } - public static RefreshToken Create(string value, UserId userId, bool longerSession) - => new(value, userId, longerSession); + public static RefreshToken Create(string value, Fingerprint fingerprint, UserId userId, bool longerSession) + => new(value, fingerprint, userId, longerSession); - public static RefreshToken Create(RefreshTokenId id, string value, UserId userId, bool longerSession) - => new(value, userId, longerSession) + public static RefreshToken Create(RefreshTokenId id, string value, Fingerprint fingerprint, UserId userId, bool longerSession) + => new(value, fingerprint, userId, longerSession) { Id = id, }; - public static RefreshToken Create(RefreshTokenId id, string value, UserId userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt) - => new(value, userId, issuedAt, expiresAt) + public static RefreshToken Create(RefreshTokenId id, string value, Fingerprint fingerprint, UserId userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt) + => new(value, fingerprint, userId, issuedAt, expiresAt) { Id = id, }; diff --git a/src/Modules/Identity/Domain/Users/User.cs b/src/Modules/Identity/Domain/Users/User.cs index ed660e749..0782242fd 100644 --- a/src/Modules/Identity/Domain/Users/User.cs +++ b/src/Modules/Identity/Domain/Users/User.cs @@ -53,9 +53,9 @@ public void SetUsername(string username) this.ValidateUsername(); } - public RefreshToken AddRefreshToken(string token, bool longerSession) + public RefreshToken AddRefreshToken(string token, Fingerprint fingerprint, bool longerSession) { - RefreshToken rt = RefreshToken.Create(token, this.Id, longerSession); + RefreshToken rt = RefreshToken.Create(token, fingerprint, this.Id, longerSession); refreshTokens.Add(rt); return rt; diff --git a/src/Modules/Identity/Domain/Users/ValueObjects/Fingerprint.cs b/src/Modules/Identity/Domain/Users/ValueObjects/Fingerprint.cs new file mode 100644 index 000000000..d8a9a5c55 --- /dev/null +++ b/src/Modules/Identity/Domain/Users/ValueObjects/Fingerprint.cs @@ -0,0 +1,3 @@ +namespace CustomCADs.Modules.Identity.Domain.Users.ValueObjects; + +public record Fingerprint(string Device = "", string? Location = null); diff --git a/src/Modules/Identity/Infrastructure/DependencyInjection.cs b/src/Modules/Identity/Infrastructure/DependencyInjection.cs index 024e7cc1e..1753d329f 100644 --- a/src/Modules/Identity/Infrastructure/DependencyInjection.cs +++ b/src/Modules/Identity/Infrastructure/DependencyInjection.cs @@ -1,6 +1,7 @@ #pragma warning disable IDE0130 using CustomCADs.Modules.Identity.Application.Contracts; using CustomCADs.Modules.Identity.Infrastructure.BackgroundJobs; +using CustomCADs.Modules.Identity.Infrastructure.Fingerprints; using CustomCADs.Modules.Identity.Infrastructure.Identity.Context; using CustomCADs.Modules.Identity.Infrastructure.Identity.Services; using CustomCADs.Modules.Identity.Infrastructure.Tokens; @@ -25,6 +26,9 @@ public async Task UpdateIdentityContextAsync() extension(IServiceCollection services) { + public IServiceCollection AddFingerprintsService() + => services.AddScoped(); + public IServiceCollection AddTokensService() => services.AddScoped(); diff --git a/src/Modules/Identity/Infrastructure/Fingerprints/DeviceDetectorFingerprintService.cs b/src/Modules/Identity/Infrastructure/Fingerprints/DeviceDetectorFingerprintService.cs new file mode 100644 index 000000000..95ef0ce19 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Fingerprints/DeviceDetectorFingerprintService.cs @@ -0,0 +1,49 @@ +using DeviceDetectorNET; +using DeviceDetectorNET.Parser; +using DeviceDetectorNET.Results; +using DeviceDetectorNET.Results.Client; + +namespace CustomCADs.Modules.Identity.Infrastructure.Fingerprints; + +using Application.Contracts; +using Domain.Users.ValueObjects; + +public class DeviceDetectorFingerprintService : IFingerprintService +{ + public DeviceDetectorFingerprintService() + { + DeviceDetector.SetVersionTruncation(VersionTruncation.VERSION_TRUNCATION_MINOR); + } + + public Fingerprint GetFingerprint(Dictionary headers) + => new( + Device: GetDevice(headers), + Location: GetLocation(headers) + ); + + private static string GetDevice(Dictionary headers) + { + DeviceDetector detector = new( + userAgent: headers["User-Agent"], + clientHints: ClientHints.Factory(headers) + ); + detector.Parse(); + + if (detector.IsBot()) + { + IEnumerable botNames = detector.GetBot().Matches.Select(x => x.Name); + return string.Join("; ", botNames); + } + + ClientMatchResult clientInfo = detector.GetClient().Match; + OsMatchResult osInfo = detector.GetOs().Match; + + string os = $"{osInfo.Name} {osInfo.Platform}"; + string client = $"{clientInfo.Name} {clientInfo.Version}"; + + return $"{os} running {client}"; + } + + private static string? GetLocation(Dictionary headers, string locationHeaderName = "CF-IPCountry") + => headers.GetValueOrDefault(locationHeaderName); +} diff --git a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRefreshTokens/Configurations.cs b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRefreshTokens/Configurations.cs index 2d78aaf86..6b4b63da2 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRefreshTokens/Configurations.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRefreshTokens/Configurations.cs @@ -8,5 +8,6 @@ public class Configurations : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) => builder + .SetValueObjects() .SetValidations(); } diff --git a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRefreshTokens/Utilities.cs b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRefreshTokens/Utilities.cs index 21652e431..a0828aaeb 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRefreshTokens/Utilities.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Configurations/AppRefreshTokens/Utilities.cs @@ -1,4 +1,5 @@ -using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; +using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; +using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -8,6 +9,17 @@ internal static class Utilities { extension(EntityTypeBuilder builder) { + internal EntityTypeBuilder SetValueObjects() + { + builder.ComplexProperty(x => x.Fingerprint, builder => + { + builder.Property(x => x.Device).IsRequired().HasColumnName(nameof(Fingerprint.Device)); + builder.Property(x => x.Location).IsRequired(false).HasColumnName(nameof(Fingerprint.Location)); + }); + + return builder; + } + internal EntityTypeBuilder SetValidations() { builder.Property(x => x.Value) diff --git a/src/Modules/Identity/Infrastructure/Identity/Mapper.cs b/src/Modules/Identity/Infrastructure/Identity/Mapper.cs index 5982af701..f1eefd785 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Mapper.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Mapper.cs @@ -7,24 +7,6 @@ namespace CustomCADs.Modules.Identity.Infrastructure.Identity; internal static class Mapper { - private static class Shallow - { - internal static RefreshToken ToRefreshToken(AppRefreshToken rt) - => RefreshToken.Create( - id: RefreshTokenId.New(rt.Id), - value: rt.Value, - userId: UserId.New(rt.UserId), - issuedAt: rt.IssuedAt, - expiresAt: rt.ExpiresAt - ); - - internal static AppRefreshToken ToAppRefreshToken(RefreshToken rt) - => new(rt.Value, rt.UserId.Value, rt.IssuedAt, rt.ExpiresAt) - { - Id = rt.Id.Value, - }; - } - extension(AppUser appUser) { internal User ToUser(string role) @@ -34,7 +16,7 @@ internal User ToUser(string role) username: appUser.Username, email: new(appUser.Email ?? string.Empty, appUser.EmailConfirmed), accountId: appUser.AccountId, - refreshTokens: [.. appUser.RefreshTokens.Select(x => Shallow.ToRefreshToken(x))] + refreshTokens: [.. appUser.RefreshTokens.Select(ToRefreshToken)] ); } @@ -50,7 +32,7 @@ internal AppUser ToAppUser() Email = user.Email.Value, EmailConfirmed = user.Email.IsVerified, AccountId = user.AccountId, - }.FillRefreshTokens([.. user.RefreshTokens.Select(x => Shallow.ToAppRefreshToken(x))]); + }.FillRefreshTokens([.. user.RefreshTokens.Select(ToAppRefreshToken)]); internal AppUser ToAppUser(string provider) @@ -63,7 +45,7 @@ internal AppUser ToAppUser(string provider) Email = user.Email.Value, EmailConfirmed = true, AccountId = user.AccountId, - }.FillRefreshTokens([.. user.RefreshTokens.Select(x => Shallow.ToAppRefreshToken(x))]); + }.FillRefreshTokens([.. user.RefreshTokens.Select(ToAppRefreshToken)]); } extension(AppRefreshToken rt) @@ -71,6 +53,7 @@ internal AppUser ToAppUser(string provider) internal RefreshToken ToRefreshToken() => RefreshToken.Create( id: RefreshTokenId.New(rt.Id), + fingerprint: rt.Fingerprint, value: rt.Value, userId: UserId.New(rt.UserId), issuedAt: rt.IssuedAt, @@ -81,7 +64,7 @@ internal RefreshToken ToRefreshToken() extension(RefreshToken rt) { internal AppRefreshToken ToAppRefreshToken() - => new(rt.Value, rt.UserId.Value, rt.IssuedAt, rt.ExpiresAt) + => new(rt.Value, rt.Fingerprint, rt.UserId.Value, rt.IssuedAt, rt.ExpiresAt) { Id = rt.Id.Value, }; diff --git a/src/Modules/Identity/Infrastructure/Identity/Migrations/20260506185244_Added_RefreshToken_Fingerprints.Designer.cs b/src/Modules/Identity/Infrastructure/Identity/Migrations/20260506185244_Added_RefreshToken_Fingerprints.Designer.cs new file mode 100644 index 000000000..8a5837f07 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Identity/Migrations/20260506185244_Added_RefreshToken_Fingerprints.Designer.cs @@ -0,0 +1,498 @@ +// +using System; +using System.Collections.Generic; +using CustomCADs.Modules.Identity.Infrastructure.Identity.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CustomCADs.Modules.Identity.Infrastructure.Migrations +{ + [DbContext(typeof(IdentityContext))] + [Migration("20260506185244_Added_RefreshToken_Fingerprints")] + partial class Added_RefreshToken_Fingerprints + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Identity") + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ExpiresAt"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("IssuedAt"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("UserId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Value"); + + b.ComplexProperty(typeof(Dictionary), "Fingerprint", "CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRefreshToken.Fingerprint#Fingerprint", b1 => + { + b1.IsRequired(); + + b1.Property("Device") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Device"); + + b1.Property("Location") + .HasColumnType("text") + .HasColumnName("Location"); + }); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AppRefreshToken", "Identity"); + }); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", "Identity"); + + b.HasData( + new + { + Id = new Guid("762ddec2-25c9-4183-9891-72a19d84a839"), + ConcurrencyStamp = "51da1b9f-803c-4bd3-9a00-da7ac259ce32", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = new Guid("e1101e2c-32cc-456f-9c82-4f1d1a65d141"), + ConcurrencyStamp = "a1a170e0-ee84-4afe-afd9-1df57009f291", + Name = "Contributor", + NormalizedName = "CONTRIBUTOR" + }, + new + { + Id = new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733"), + ConcurrencyStamp = "1a8ba0a7-4853-42da-980d-3107784e7ab1", + Name = "Designer", + NormalizedName = "DESIGNER" + }, + new + { + Id = new Guid("fad1b19d-5333-4633-bd84-d67c64649f65"), + ConcurrencyStamp = "42174679-32f1-48b0-9524-0f00791ec760", + Name = "Administrator", + NormalizedName = "ADMINISTRATOR" + }); + }); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("AccountId"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsSSO") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("Provider") + .HasColumnType("text") + .HasColumnName("Provider"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", "Identity"); + + b.HasData( + new + { + Id = new Guid("e38c495f-b1f3-4226-d289-08dd11623eb9"), + AccessFailedCount = 0, + AccountId = new Guid("2da61b05-1a27-4af9-9df2-be4f1f4e835f"), + ConcurrencyStamp = "0c5bbfb2-d132-407b-9b1b-e1e640ccc14e", + Email = "ivanzlatinov006@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "IVANZLATINOV006@GMAIL.COM", + NormalizedUserName = "FOR7A7A", + PasswordHash = "AQAAAAIAAYagAAAAEJFCGOTxNAgjhqU5lrA63WEtv924ujxXHt0x1R70qlS8dV9Pzz4II8GOgjVOaRzuDQ==", + PhoneNumberConfirmed = false, + SecurityStamp = "3A6TFN6VVZNRZEG22J777XJTPQY7342B", + TwoFactorEnabled = false, + UserName = "For7a7a" + }, + new + { + Id = new Guid("af840410-f3f2-4a3b-d28a-08dd11623eb9"), + AccessFailedCount = 0, + AccountId = new Guid("6d963818-23dc-4e9a-aaa8-b4c77252bc97"), + ConcurrencyStamp = "c77927de-61e7-4d53-be8d-a5390fafc75c", + Email = "PDMatsaliev20@codingburgas.bg", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "PDMATSALIEV20@CODINGBURGAS.BG", + NormalizedUserName = "PDMATSALIEV20", + PasswordHash = "AQAAAAIAAYagAAAAEGjQ1Zes3r2XJgjoHQykiyr11OgUEDw+YDnOKeENyN7Kqi9RWKKRCtwd7ZtEyywdYA==", + PhoneNumberConfirmed = false, + SecurityStamp = "NWGZ3JTQSDNS346DMU7RP4IT4BDLHIQC", + TwoFactorEnabled = false, + UserName = "PDMatsaliev20" + }, + new + { + Id = new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde"), + AccessFailedCount = 0, + AccountId = new Guid("8d477999-0580-4770-8864-e9ba4bed9cd1"), + ConcurrencyStamp = "c5940d6f-d5c0-4f84-a262-da9b07525c3c", + Email = "john.cad@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "JOHN.CAD@GMAIL.COM", + NormalizedUserName = "JOHN_CAD", + PasswordHash = "AQAAAAIAAYagAAAAEEUe31maWfuZY6V8MQBzUWKerMKobDukREinVfML3Yl2z+Nr6IIQZKvX4WKqbTUw6w==", + PhoneNumberConfirmed = false, + SecurityStamp = "FNNIT3NPOZKZK2E67WFLV5R3RGVBX7LV", + TwoFactorEnabled = false, + UserName = "John_CAD" + }, + new + { + Id = new Guid("4337a774-2c5c-4c27-d28b-08dd11623eb9"), + AccessFailedCount = 0, + AccountId = new Guid("0fb3212f-7d51-4586-8fc2-0f333ec9fbc1"), + ConcurrencyStamp = "c11d44ef-29c5-43ac-89f9-a2e7cd482ec8", + Email = "boriskolev2006@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "BORISKOLEV2006@GMAIL.COM", + NormalizedUserName = "ORACLE3000", + PasswordHash = "AQAAAAIAAYagAAAAEJGRCbKUsSh9BwxPXoIQRG1AVXYDfmWsY5vEA4aEnqBBQAzdcgLFCHUtpwd86+B4mA==", + PhoneNumberConfirmed = false, + SecurityStamp = "MHG763AUJVOJPKKWUC64FQSF7DIVVBOU", + TwoFactorEnabled = false, + UserName = "Oracle3000" + }, + new + { + Id = new Guid("cb7749fb-3fff-4902-d28c-08dd11623eb9"), + AccessFailedCount = 0, + AccountId = new Guid("e995039c-a535-4f20-8288-7aadcb71b252"), + ConcurrencyStamp = "5c94b43f-861c-4efa-a670-5627e49d354d", + Email = "ivanangelov414@gmail.com", + EmailConfirmed = true, + IsSSO = false, + LockoutEnabled = true, + NormalizedEmail = "IVANANGELOV414@GMAIL.COM", + NormalizedUserName = "NINJATABG", + PasswordHash = "AQAAAAIAAYagAAAAEFqtQ33BvarNRyFcmV4z48fPBlIY8zd0de90qq3Cdm1Row+2WRmEjVJk1yPadBkrSA==", + PhoneNumberConfirmed = false, + SecurityStamp = "YIA26UZDSN2V2U5PVDEK4F3EJS3P5D3X", + TwoFactorEnabled = false, + UserName = "NinjataBG" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", "Identity"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", "Identity"); + + b.HasData( + new + { + UserId = new Guid("e38c495f-b1f3-4226-d289-08dd11623eb9"), + RoleId = new Guid("762ddec2-25c9-4183-9891-72a19d84a839") + }, + new + { + UserId = new Guid("af840410-f3f2-4a3b-d28a-08dd11623eb9"), + RoleId = new Guid("e1101e2c-32cc-456f-9c82-4f1d1a65d141") + }, + new + { + UserId = new Guid("a8145f5f-a3a4-4f06-9461-9f24b9f23fde"), + RoleId = new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733") + }, + new + { + UserId = new Guid("4337a774-2c5c-4c27-d28b-08dd11623eb9"), + RoleId = new Guid("f3ad41d3-ee90-4988-9195-8b2a8f4f2733") + }, + new + { + UserId = new Guid("cb7749fb-3fff-4902-d28c-08dd11623eb9"), + RoleId = new Guid("fad1b19d-5333-4633-bd84-d67c64649f65") + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", "Identity"); + }); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRefreshToken", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppUser", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Identity/Infrastructure/Identity/Migrations/20260506185244_Added_RefreshToken_Fingerprints.cs b/src/Modules/Identity/Infrastructure/Identity/Migrations/20260506185244_Added_RefreshToken_Fingerprints.cs new file mode 100644 index 000000000..8597b3ae9 --- /dev/null +++ b/src/Modules/Identity/Infrastructure/Identity/Migrations/20260506185244_Added_RefreshToken_Fingerprints.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CustomCADs.Modules.Identity.Infrastructure.Migrations; + +/// +public partial class Added_RefreshToken_Fingerprints : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Device", + schema: "Identity", + table: "AppRefreshToken", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Location", + schema: "Identity", + table: "AppRefreshToken", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Device", + schema: "Identity", + table: "AppRefreshToken"); + + migrationBuilder.DropColumn( + name: "Location", + schema: "Identity", + table: "AppRefreshToken"); + } +} diff --git a/src/Modules/Identity/Infrastructure/Identity/Migrations/IdentityContextModelSnapshot.cs b/src/Modules/Identity/Infrastructure/Identity/Migrations/IdentityContextModelSnapshot.cs index 53b045dec..5fd21bc06 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Migrations/IdentityContextModelSnapshot.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Migrations/IdentityContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using CustomCADs.Modules.Identity.Infrastructure.Identity.Context; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -46,6 +47,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("Value"); + b.ComplexProperty(typeof(Dictionary), "Fingerprint", "CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities.AppRefreshToken.Fingerprint#Fingerprint", b1 => + { + b1.IsRequired(); + + b1.Property("Device") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Device"); + + b1.Property("Location") + .HasColumnType("text") + .HasColumnName("Location"); + }); + b.HasKey("Id"); b.HasIndex("UserId"); diff --git a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs index 2b9e6072d..352c6ec68 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs @@ -3,6 +3,7 @@ using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; using CustomCADs.Shared.Application.Exceptions; using CustomCADs.Shared.Domain.TypedIds.Accounts; +using CustomCADs.Shared.Domain.TypedIds.Identity; using Microsoft.EntityFrameworkCore; namespace CustomCADs.Modules.Identity.Infrastructure.Identity.Services; @@ -14,6 +15,20 @@ private async Task MapToUserAsync(AppUser user) role: (await manager.GetRolesAsync(user).ConfigureAwait(false)).Single() ); + private async Task<(User User, RefreshToken RefreshToken)> GetByRefreshTokenAsync(RefreshTokenId refreshTokenId) + { + AppUser appUser = await context.Users + .Include(x => x.RefreshTokens) + .FirstOrDefaultAsync(x => x.RefreshTokens.Any(x => x.Id == refreshTokenId.Value)) + .ConfigureAwait(false) + ?? throw CustomNotFoundException.ByProp(nameof(RefreshToken.Id), refreshTokenId); + + return ( + User: await MapToUserAsync(appUser).ConfigureAwait(false), + RefreshToken: appUser.RefreshTokens.First(x => x.Id == refreshTokenId.Value).ToRefreshToken() + ); + } + #region GetUserByX public async Task GetByAccountIdAsync(AccountId accountId) { @@ -63,7 +78,7 @@ public async Task GetByEmailAsync(string email) } #endregion - #region GetPropertyX + #region GetX public async Task GetExistsByUsernameAsync(string username) => await context.Users .AnyAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) diff --git a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs index 6bb4c6a89..605909cd5 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs @@ -84,6 +84,18 @@ public async Task SaveRefreshTokensAsync(User user) await context.SaveChangesAsync().ConfigureAwait(false); } + public async Task RevokeRefreshTokenAsync(RefreshTokenId id) + { + (User User, RefreshToken RefreshToken) = await GetByRefreshTokenAsync(id).ConfigureAwait(false); + User.RemoveRefreshToken(RefreshToken); + + AppUser appUser = await context.Users.FirstOrDefaultAsync(x => x.Id == User.Id.Value).ConfigureAwait(false) + ?? throw CustomNotFoundException.ByProp(nameof(User.Id), User.Id); + + appUser.FillRefreshTokens([.. User.RefreshTokens.Select(x => x.ToAppRefreshToken())]); + await context.SaveChangesAsync().ConfigureAwait(false); + } + public async Task RevokeRefreshTokenAsync(string token) { (User User, RefreshToken RefreshToken) = await GetByRefreshTokenAsync(token).ConfigureAwait(false); diff --git a/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppRefreshToken.cs b/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppRefreshToken.cs index a1295aa8a..b88b1f31c 100644 --- a/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppRefreshToken.cs +++ b/src/Modules/Identity/Infrastructure/Identity/ShadowEntities/AppRefreshToken.cs @@ -1,11 +1,14 @@ +using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; + namespace CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; public class AppRefreshToken { public AppRefreshToken() { } - public AppRefreshToken(string value, Guid userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt) + public AppRefreshToken(string value, Fingerprint fingerprint, Guid userId, DateTimeOffset issuedAt, DateTimeOffset expiresAt) { Value = value; + Fingerprint = fingerprint; UserId = userId; IssuedAt = issuedAt; ExpiresAt = expiresAt; @@ -15,6 +18,7 @@ public AppRefreshToken(string value, Guid userId, DateTimeOffset issuedAt, DateT public DateTimeOffset IssuedAt { get; init; } public DateTimeOffset ExpiresAt { get; init; } public string Value { get; private set; } = string.Empty; + public Fingerprint Fingerprint { get; private set; } = new(); public Guid UserId { get; private set; } public AppUser User { get; init; } = null!; } diff --git a/src/Modules/Identity/Infrastructure/Modules.Identity.Infrastructure.csproj b/src/Modules/Identity/Infrastructure/Modules.Identity.Infrastructure.csproj index fd36a7ab7..049bb52e9 100644 --- a/src/Modules/Identity/Infrastructure/Modules.Identity.Infrastructure.csproj +++ b/src/Modules/Identity/Infrastructure/Modules.Identity.Infrastructure.csproj @@ -1,6 +1,7 @@  + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Presentation/Program.cs b/src/Presentation/Program.cs index 73ed104d1..c9fbc91da 100644 --- a/src/Presentation/Program.cs +++ b/src/Presentation/Program.cs @@ -19,6 +19,7 @@ // External Services builder.Services.AddEmailService(builder.Configuration); builder.Services.AddTokensService(builder.Configuration); +builder.Services.AddFingerprintsService(); builder.Services.AddPaymentService(builder.Configuration); builder.Services.AddDeliveryService(builder.Configuration); builder.Services.AddStorageService(builder.Configuration); diff --git a/src/Shared/API/Extensions/HttpExtensions.cs b/src/Shared/API/Extensions/HttpExtensions.cs index 017b896d5..8a52ad3c1 100644 --- a/src/Shared/API/Extensions/HttpExtensions.cs +++ b/src/Shared/API/Extensions/HttpExtensions.cs @@ -19,6 +19,12 @@ public static class HttpExtensions || HttpMethods.IsPatch(request.Method) || HttpMethods.IsDelete(request.Method); + public Dictionary HeadersDictionary => + request.Headers.ToDictionary( + x => x.Key, + x => x.Value.FirstOrDefault() + ); + public bool TryGetIdempotencyKey(out Guid idempotencyKey, string idempotencyHeader = "Idempotency-Key") { string? header = request.Headers[idempotencyHeader]; From 5a100c8df6fed2b058467822c362eb591162d1e2 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Mon, 11 May 2026 23:33:40 +0300 Subject: [PATCH 16/22] Improved Accounts/Identity - Fixed Account soft delete - Enhanced 'DeleteAccount' security - Replaced 'GetAccountId' method with Id from JWT - Improved Exception handling - Added service helper methods --- .../Internal/Delete/DeleteAccountHandler.cs | 4 +-- .../Accounts/Domain/Accounts/Account.cs | 6 ++++ .../Identity/Delete/DeleteAccountEndpoint.cs | 2 +- .../ToggleViewedProductsTrackingEndpoint.cs | 3 +- .../Application/Contracts/IUserService.cs | 3 +- .../CustomAuthorizationExceptionExtensions.cs | 15 +++++++++ .../Internal/Delete/DeleteUserCommand.cs | 6 ++-- .../Internal/Delete/DeleteUserHandler.cs | 6 ++-- .../Internal/Refresh/RefreshUserHandler.cs | 14 +++----- .../ToggleViewedProductsTrackingCommand.cs | 5 ++- .../ToggleViewedProductsTrackingHandler.cs | 5 +-- .../Application/Users/UserDeletedHandler.cs | 2 +- .../Identity/Services/AppUserService.Reads.cs | 32 +++++-------------- .../Services/AppUserService.Writes.cs | 32 +++++++++---------- .../AccountDeletedApplicationEvent.cs | 2 +- .../Delete/DeleteUserHandlerUnitTests.cs | 9 ++---- ...eViewedProductsTrackingHandlerUnitTests.cs | 21 ++---------- .../Users/UserDeletedHandlerUnitTests.cs | 4 +-- 18 files changed, 76 insertions(+), 95 deletions(-) create mode 100644 src/Modules/Identity/Application/Extensions/CustomAuthorizationExceptionExtensions.cs diff --git a/src/Modules/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandler.cs b/src/Modules/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandler.cs index 2322a36fc..c64d1addc 100644 --- a/src/Modules/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandler.cs +++ b/src/Modules/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandler.cs @@ -22,8 +22,8 @@ public async Task Handle(DeleteAccountCommand req, CancellationToken ct) await uow.SaveChangesAsync(ct).ConfigureAwait(false); await raiser.RaiseApplicationEventAsync( - @event: new AccountDeletedApplicationEvent( - account.Username + @event: new AccountDeletedApplicationEvent( + account.Id ) ).ConfigureAwait(false); } diff --git a/src/Modules/Accounts/Domain/Accounts/Account.cs b/src/Modules/Accounts/Domain/Accounts/Account.cs index c73a5e4a9..55793c84d 100644 --- a/src/Modules/Accounts/Domain/Accounts/Account.cs +++ b/src/Modules/Accounts/Domain/Accounts/Account.cs @@ -109,6 +109,12 @@ public Account Delete() IsDeleted = true; DeletedAt = DateTimeOffset.UtcNow; + Username = this.Id.ToString(); + Email = this.Id.ToString(); + + FirstName = null; + LastName = null; + return this; } } diff --git a/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs b/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs index 1c2e99c0a..ef54d3abe 100644 --- a/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Delete/DeleteAccountEndpoint.cs @@ -20,7 +20,7 @@ public override void Configure() public override async Task HandleAsync(CancellationToken ct) { await sender.SendCommandAsync( - command: new DeleteUserCommand(Username: User.Name), + command: new DeleteUserCommand(User.AccountId), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs b/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs index 5ba3d68f2..9ca86b99f 100644 --- a/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs +++ b/src/Modules/Identity/API/Identity/Patch/ToggleViewedProductsTracking/ToggleViewedProductsTrackingEndpoint.cs @@ -19,7 +19,8 @@ public override async Task HandleAsync(CancellationToken ct) { await sender.SendCommandAsync( command: new ToggleViewedProductsTrackingCommand( - Username: User.Name + Username: User.Name, + CallerId: User.AccountId ), ct: ct ).ConfigureAwait(false); diff --git a/src/Modules/Identity/Application/Contracts/IUserService.cs b/src/Modules/Identity/Application/Contracts/IUserService.cs index a90a37883..b8b1944c3 100644 --- a/src/Modules/Identity/Application/Contracts/IUserService.cs +++ b/src/Modules/Identity/Application/Contracts/IUserService.cs @@ -16,14 +16,13 @@ public interface IUserService Task GetExistsByUsernameAsync(string username); Task GetExistsByEmailAsync(string email); Task GetIsSSOByEmailAsync(string email); - Task GetAccountIdAsync(string username); Task GetIsLockedOutAsync(string username); #endregion #region Lifecycle Task CreateAsync(User user, string password); Task CreateSSOAsync(User user, string provider); - Task DeleteAsync(string username); + Task DeleteAsync(AccountId id); #endregion #region Mutation diff --git a/src/Modules/Identity/Application/Extensions/CustomAuthorizationExceptionExtensions.cs b/src/Modules/Identity/Application/Extensions/CustomAuthorizationExceptionExtensions.cs new file mode 100644 index 000000000..07e93f6ac --- /dev/null +++ b/src/Modules/Identity/Application/Extensions/CustomAuthorizationExceptionExtensions.cs @@ -0,0 +1,15 @@ +using CustomCADs.Shared.Application.Exceptions; + +namespace CustomCADs.Modules.Identity.Application.Extensions; + +public static class CustomAuthorizationExceptionExtensions +{ + extension(CustomAuthorizationException ex) + { + public static CustomAuthorizationException NoRefreshToken() + => CustomAuthorizationException.Custom("No Refresh Token found."); + + public static CustomAuthorizationException RefreshTokenExpired() + => CustomAuthorizationException.Custom("Refresh Token found, but expired."); + } +} diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserCommand.cs index 59f0dc1f5..3fcb63cb3 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserCommand.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserCommand.cs @@ -1,5 +1,5 @@ +using CustomCADs.Shared.Domain.TypedIds.Accounts; + namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete; -public sealed record DeleteUserCommand( - string Username -) : ICommand; +public sealed record DeleteUserCommand(AccountId CallerId) : ICommand; diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandler.cs index ce3546f6c..c6862c2cc 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandler.cs @@ -1,6 +1,5 @@ using CustomCADs.Shared.Application.Abstractions.Events; using CustomCADs.Shared.Application.Events.Identity; -using CustomCADs.Shared.Domain.TypedIds.Accounts; namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.Delete; @@ -11,11 +10,10 @@ IEventRaiser raiser { public async Task Handle(DeleteUserCommand req, CancellationToken ct = default) { - AccountId accountId = await service.GetAccountIdAsync(req.Username).ConfigureAwait(false); - await service.DeleteAsync(req.Username).ConfigureAwait(false); + await service.DeleteAsync(req.CallerId).ConfigureAwait(false); await raiser.RaiseApplicationEventAsync( - @event: new UserDeletedApplicationEvent(accountId) + @event: new UserDeletedApplicationEvent(req.CallerId) ).ConfigureAwait(false); } } diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs index 70bb7d74b..091052b5c 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandler.cs @@ -1,4 +1,5 @@ -using CustomCADs.Modules.Identity.Application.Users.Dtos; +using CustomCADs.Modules.Identity.Application.Extensions; +using CustomCADs.Modules.Identity.Application.Users.Dtos; using CustomCADs.Modules.Identity.Domain.Users.Entities; using CustomCADs.Shared.Application.Exceptions; @@ -13,20 +14,15 @@ public async Task Handle(RefreshUserCommand req, CancellationToken ct { if (string.IsNullOrEmpty(req.Token)) { - throw CustomAuthorizationException.Custom("No Refresh Token found."); + throw CustomAuthorizationException.NoRefreshToken(); } (User User, RefreshToken RefreshToken) = await service.GetByRefreshTokenAsync(req.Token).ConfigureAwait(false); if (RefreshToken.ExpiresAt < DateTime.UtcNow) { - throw CustomAuthorizationException.Custom("Refresh Token found, but expired."); + throw CustomAuthorizationException.RefreshTokenExpired(); } - RefreshToken rt = tokenService.IssueRefreshToken( - createRefreshToken: (token) => User.AddRefreshToken(token, req.Fingerprint, longerSession: false) - ); - await service.SaveRefreshTokensAsync(User).ConfigureAwait(false); - - return tokenService.IssueTokens(User, rt); + return tokenService.IssueTokens(User, RefreshToken); } } diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingCommand.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingCommand.cs index 4b0f3e242..24cc7aea0 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingCommand.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingCommand.cs @@ -1,5 +1,8 @@ +using CustomCADs.Shared.Domain.TypedIds.Accounts; + namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ToggleViewedProductsTracking; public sealed record ToggleViewedProductsTrackingCommand( - string Username + string Username, + AccountId CallerId ) : ICommand; diff --git a/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandler.cs b/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandler.cs index 7cdaead2e..c8a2035a1 100644 --- a/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandler.cs +++ b/src/Modules/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandler.cs @@ -2,19 +2,16 @@ using CustomCADs.Shared.Application.Abstractions.Requests.Sender; using CustomCADs.Shared.Application.Events.Identity; using CustomCADs.Shared.Application.UseCases.Accounts.Queries; -using CustomCADs.Shared.Domain.TypedIds.Accounts; namespace CustomCADs.Modules.Identity.Application.Users.Commands.Internal.ToggleViewedProductsTracking; public sealed class ToggleViewedProductsTrackingHandler( - IUserService service, IRequestSender sender, IEventRaiser raiser ) : ICommandHandler { public async Task Handle(ToggleViewedProductsTrackingCommand req, CancellationToken ct = default) { - AccountId accountId = await service.GetAccountIdAsync(req.Username).ConfigureAwait(false); AccountInfoDto info = await sender.SendQueryAsync( query: new GetAccountInfoByUsernameQuery(req.Username), ct: ct @@ -22,7 +19,7 @@ public async Task Handle(ToggleViewedProductsTrackingCommand req, CancellationTo await raiser.RaiseApplicationEventAsync( @event: new UserEditedApplicationEvent( - Id: accountId, + Id: req.CallerId, TrackViewedProducts: !info.TrackViewedProducts ) ).ConfigureAwait(false); diff --git a/src/Modules/Identity/Application/Users/Events/Application/Users/UserDeletedHandler.cs b/src/Modules/Identity/Application/Users/Events/Application/Users/UserDeletedHandler.cs index 71cbbc203..3b8c832ba 100644 --- a/src/Modules/Identity/Application/Users/Events/Application/Users/UserDeletedHandler.cs +++ b/src/Modules/Identity/Application/Users/Events/Application/Users/UserDeletedHandler.cs @@ -6,6 +6,6 @@ public class UserDeletedHandler(IUserService service) { public async Task HandleAsync(AccountDeletedApplicationEvent ae) { - await service.DeleteAsync(ae.Username).ConfigureAwait(false); + await service.DeleteAsync(ae.Id).ConfigureAwait(false); } } diff --git a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs index 352c6ec68..da4597d6d 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Reads.cs @@ -15,6 +15,9 @@ private async Task MapToUserAsync(AppUser user) role: (await manager.GetRolesAsync(user).ConfigureAwait(false)).Single() ); + private static IQueryable QueryByUsername(IQueryable query, string username) + => query.Where(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)); + private async Task<(User User, RefreshToken RefreshToken)> GetByRefreshTokenAsync(RefreshTokenId refreshTokenId) { AppUser appUser = await context.Users @@ -43,9 +46,8 @@ public async Task GetByAccountIdAsync(AccountId accountId) public async Task GetByUsernameAsync(string username) { - AppUser appUser = await context.Users - .Include(x => x.RefreshTokens) - .FirstOrDefaultAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) + AppUser appUser = await QueryByUsername(context.Users.Include(x => x.RefreshTokens), username) + .FirstOrDefaultAsync() .ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(username), username); @@ -80,9 +82,7 @@ public async Task GetByEmailAsync(string email) #region GetX public async Task GetExistsByUsernameAsync(string username) - => await context.Users - .AnyAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) - .ConfigureAwait(false); + => await QueryByUsername(context.Users, username).AnyAsync().ConfigureAwait(false); public async Task GetExistsByEmailAsync(string email) => await context.Users @@ -94,26 +94,10 @@ public async Task GetIsSSOByEmailAsync(string email) .AnyAsync(x => x.Email == email && x.IsSSO) .ConfigureAwait(false); - public async Task GetAccountIdAsync(string username) - { - AccountId accountId = await context.Users - .Where(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) - .Select(x => x.AccountId) - .FirstOrDefaultAsync() - .ConfigureAwait(false); - - if (accountId.IsEmpty()) - { - throw CustomNotFoundException.ByProp(nameof(username), username); - } - - return accountId; - } - public async Task GetIsLockedOutAsync(string username) { - AppUser appUser = await context.Users - .FirstOrDefaultAsync(x => x.UserName == (x.IsSSO ? x.Provider + '/' + username : username)) + AppUser appUser = await QueryByUsername(context.Users, username) + .FirstOrDefaultAsync() .ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(username), username); diff --git a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs index 605909cd5..b5b5b79e8 100644 --- a/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs +++ b/src/Modules/Identity/Infrastructure/Identity/Services/AppUserService.Writes.cs @@ -2,6 +2,7 @@ using CustomCADs.Modules.Identity.Domain.Users.Entities; using CustomCADs.Modules.Identity.Infrastructure.Identity.ShadowEntities; using CustomCADs.Shared.Application.Exceptions; +using CustomCADs.Shared.Domain.TypedIds.Accounts; using CustomCADs.Shared.Domain.TypedIds.Identity; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -15,30 +16,29 @@ public async Task CreateAsync(User user, string password) { AppUser appUser = user.ToAppUser(); await manager.CreateAsync(appUser, password).ConfigureAwait(false); - - IdentityResult result = await manager.AddToRoleAsync(appUser, user.Role).ConfigureAwait(false); - if (!result.Succeeded) - { - throw new CustomException($"Couldn't create an account for: {user.Username}."); - } + await AddToRoleAsync(appUser, user.Role).ConfigureAwait(false); } public async Task CreateSSOAsync(User user, string provider) { AppUser appUser = user.ToAppUser(provider); await manager.CreateAsync(appUser).ConfigureAwait(false); + await AddToRoleAsync(appUser, user.Role).ConfigureAwait(false); + } - IdentityResult result = await manager.AddToRoleAsync(appUser, user.Role).ConfigureAwait(false); + private async Task AddToRoleAsync(AppUser user, string role) + { + IdentityResult result = await manager.AddToRoleAsync(user, role).ConfigureAwait(false); if (!result.Succeeded) { throw new CustomException($"Couldn't create an account for: {user.Username}."); } } - public async Task DeleteAsync(string username) + public async Task DeleteAsync(AccountId id) { - AppUser appUser = await manager.FindByNameAsync(username).ConfigureAwait(false) - ?? throw CustomNotFoundException.ByProp(nameof(username), username); + AppUser appUser = await context.Users.FirstOrDefaultAsync(x => x.AccountId == id).ConfigureAwait(false) + ?? throw CustomNotFoundException.ByProp(nameof(User.AccountId), id); await manager.DeleteAsync(appUser).ConfigureAwait(false); } @@ -47,7 +47,7 @@ public async Task DeleteAsync(string username) #region Mutation public async Task CheckPasswordAsync(string username, string password) { - AppUser appUser = await manager.FindByNameAsync(username).ConfigureAwait(false) + AppUser appUser = await QueryByUsername(context.Users, username).FirstOrDefaultAsync().ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(username), username); bool success = await manager.CheckPasswordAsync(appUser, password).ConfigureAwait(false); @@ -65,7 +65,7 @@ public async Task CheckPasswordAsync(string username, string password) public async Task UpdateUsernameAsync(UserId id, string username) { - AppUser appUser = await manager.FindByIdAsync(id.ToString()).ConfigureAwait(false) + AppUser appUser = await context.Users.FirstOrDefaultAsync(x => x.Id == id.Value).ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(id), id); if (appUser.Username != username) appUser.Username = username; @@ -112,7 +112,7 @@ public async Task RevokeRefreshTokenAsync(string token) #region Token Generation public async Task GenerateEmailConfirmationTokenAsync(string username) { - AppUser appUser = await manager.FindByNameAsync(username).ConfigureAwait(false) + AppUser appUser = await QueryByUsername(context.Users, username).FirstOrDefaultAsync().ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(username), username); return await manager.GenerateEmailConfirmationTokenAsync(appUser).ConfigureAwait(false); @@ -120,7 +120,7 @@ public async Task GenerateEmailConfirmationTokenAsync(string username) public async Task ConfirmEmailAsync(string username, string token) { - AppUser appUser = await manager.FindByNameAsync(username).ConfigureAwait(false) + AppUser appUser = await QueryByUsername(context.Users, username).FirstOrDefaultAsync().ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(username), username); IdentityResult result = await manager.ConfirmEmailAsync(appUser, token).ConfigureAwait(false); @@ -132,7 +132,7 @@ public async Task ConfirmEmailAsync(string username, string token) public async Task GeneratePasswordResetTokenAsync(string email) { - AppUser appUser = await manager.FindByEmailAsync(email).ConfigureAwait(false) + AppUser appUser = await context.Users.FirstOrDefaultAsync(x => x.Email == email).ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(email), email); return await manager.GeneratePasswordResetTokenAsync(appUser).ConfigureAwait(false); @@ -140,7 +140,7 @@ public async Task GeneratePasswordResetTokenAsync(string email) public async Task ResetPasswordAsync(string email, string token, string newPassword) { - AppUser appUser = await manager.FindByEmailAsync(email).ConfigureAwait(false) + AppUser appUser = await context.Users.FirstOrDefaultAsync(x => x.Email == email).ConfigureAwait(false) ?? throw CustomNotFoundException.ByProp(nameof(email), email); IdentityResult result = await manager.ResetPasswordAsync(appUser, token, newPassword).ConfigureAwait(false); diff --git a/src/Shared/Application/Events/Account/Accounts/AccountDeletedApplicationEvent.cs b/src/Shared/Application/Events/Account/Accounts/AccountDeletedApplicationEvent.cs index 988e78b68..28efee03d 100644 --- a/src/Shared/Application/Events/Account/Accounts/AccountDeletedApplicationEvent.cs +++ b/src/Shared/Application/Events/Account/Accounts/AccountDeletedApplicationEvent.cs @@ -1,5 +1,5 @@ namespace CustomCADs.Shared.Application.Events.Account.Accounts; public record AccountDeletedApplicationEvent( - string Username + AccountId Id ) : BaseApplicationEvent; diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandlerUnitTests.cs index f3b8f13b8..b9e554f5f 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Delete/DeleteUserHandlerUnitTests.cs @@ -18,29 +18,26 @@ public class DeleteUserHandlerUnitTests : UsersBaseUnitTests public DeleteUserHandlerUnitTests() { handler = new(service.Object, raiser.Object); - - service.Setup(x => x.GetAccountIdAsync(user.Username)).ReturnsAsync(ValidAccountId); } [Fact] public async Task Handle_ShouldCallService() { // Arrange - DeleteUserCommand command = new(user.Username); + DeleteUserCommand command = new(user.AccountId); // Act await handler.Handle(command, ct); // Assert - service.Verify(x => x.GetAccountIdAsync(MaxValidUsername), Times.Once()); - service.Verify(x => x.DeleteAsync(user.Username), Times.Once()); + service.Verify(x => x.DeleteAsync(user.AccountId), Times.Once()); } [Fact] public async Task Handle_ShouldRaiseEvents() { // Arrange - DeleteUserCommand command = new(user.Username); + DeleteUserCommand command = new(user.AccountId); // Act await handler.Handle(command, ct); diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandlerUnitTests.cs index 8edc5dd57..41ab7eb44 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/ToggleViewedProductsTracking/ToggleViewedProductsTrackingHandlerUnitTests.cs @@ -12,7 +12,6 @@ namespace CustomCADs.UnitTests.Identity.Application.Users.Commands.Internal.Togg public class ToggleViewedProductsTrackingHandlerUnitTests : UsersBaseUnitTests { private readonly ToggleViewedProductsTrackingHandler handler; - private readonly Mock service = new(); private readonly Mock sender = new(); private readonly Mock raiser = new(); @@ -20,33 +19,19 @@ public class ToggleViewedProductsTrackingHandlerUnitTests : UsersBaseUnitTests public ToggleViewedProductsTrackingHandlerUnitTests() { - handler = new(service.Object, sender.Object, raiser.Object); + handler = new(sender.Object, raiser.Object); - service.Setup(x => x.GetAccountIdAsync(MaxValidUsername)).ReturnsAsync(ValidAccountId); sender.Setup(x => x.SendQueryAsync( It.Is(x => x.Username == MaxValidUsername), ct )).ReturnsAsync(new AccountInfoDto(ValidAccountId, default, InitialTrackViewedProducts, null, null)); } - [Fact] - public async Task Handle_ShouldCallService() - { - // Arrange - ToggleViewedProductsTrackingCommand command = new(MaxValidUsername); - - // Act - await handler.Handle(command, ct); - - // Assert - service.Verify(x => x.GetAccountIdAsync(MaxValidUsername), Times.Once()); - } - [Fact] public async Task Handle_ShouldSendRequests() { // Arrange - ToggleViewedProductsTrackingCommand command = new(MaxValidUsername); + ToggleViewedProductsTrackingCommand command = new(MaxValidUsername, ValidAccountId); // Act await handler.Handle(command, ct); @@ -62,7 +47,7 @@ public async Task Handle_ShouldSendRequests() public async Task Handle_ShouldRaiseEvents() { // Arrange - ToggleViewedProductsTrackingCommand command = new(MaxValidUsername); + ToggleViewedProductsTrackingCommand command = new(MaxValidUsername, ValidAccountId); // Act await handler.Handle(command, ct); diff --git a/tests/UnitTests/Identity/Application/Users/Events/Application/Users/UserDeletedHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Events/Application/Users/UserDeletedHandlerUnitTests.cs index 4701cce79..3b3728fab 100644 --- a/tests/UnitTests/Identity/Application/Users/Events/Application/Users/UserDeletedHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Events/Application/Users/UserDeletedHandlerUnitTests.cs @@ -20,12 +20,12 @@ public UserDeletedHandlerUnitTests() public async Task Handle_ShouldCallService() { // Arrange - AccountDeletedApplicationEvent ae = new(MaxValidUsername); + AccountDeletedApplicationEvent ae = new(ValidAccountId); // Act await handler.HandleAsync(ae); // Assert - service.Verify(x => x.DeleteAsync(MaxValidUsername), Times.Once()); + service.Verify(x => x.DeleteAsync(ValidAccountId), Times.Once()); } } From 0804d37944cfa96cf30bc02853634f7529bd0373 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Mon, 11 May 2026 23:36:47 +0300 Subject: [PATCH 17/22] SDK update for Identity API --- sdk/js/react/src/api/identity/common.ts | 8 ++++++++ .../react/src/api/identity/identity/delete-fingerprint.ts | 7 +++++++ sdk/js/react/src/api/identity/identity/index.ts | 6 ++++++ sdk/js/react/src/api/identity/identity/my-account.ts | 5 +++-- sdk/js/react/src/api/identity/identity/types.ts | 2 +- sdk/js/react/src/hooks/mutations/identity/identity.ts | 6 ++++++ 6 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 sdk/js/react/src/api/identity/identity/delete-fingerprint.ts diff --git a/sdk/js/react/src/api/identity/common.ts b/sdk/js/react/src/api/identity/common.ts index 388ecf776..b591746f2 100644 --- a/sdk/js/react/src/api/identity/common.ts +++ b/sdk/js/react/src/api/identity/common.ts @@ -3,4 +3,12 @@ export type ViewedProduct = { viewedAt: string; }; +export type Fingerprint = { + id: string; + device: string; + location?: string; + deleteAllowed: boolean; + issuedAt: string; +}; + export const IDENTITY_BASE_PATH = '/identity'; diff --git a/sdk/js/react/src/api/identity/identity/delete-fingerprint.ts b/sdk/js/react/src/api/identity/identity/delete-fingerprint.ts new file mode 100644 index 000000000..c2056ba4c --- /dev/null +++ b/sdk/js/react/src/api/identity/identity/delete-fingerprint.ts @@ -0,0 +1,7 @@ +import { IDENTITY_BASE_PATH } from '../common'; + +export type Request = { + refreshTokenId: string; +}; + +export const url = () => `${IDENTITY_BASE_PATH}/fingerprint`; diff --git a/sdk/js/react/src/api/identity/identity/index.ts b/sdk/js/react/src/api/identity/identity/index.ts index 67b7aca81..07cc2e831 100644 --- a/sdk/js/react/src/api/identity/identity/index.ts +++ b/sdk/js/react/src/api/identity/identity/index.ts @@ -8,6 +8,7 @@ import * as refreshResources from './refresh'; import * as logoutResources from './logout'; import * as deleteResources from './delete'; import * as deleteViewedProductResources from './delete-viewed-product'; +import * as deleteFingerprintResources from './delete-fingerprint'; import * as changeNamesResources from './change-names'; import * as toggleTrackViewedProductsResources from './toggle-track-viewed-products'; import * as forgotPasswordResources from './forgot-password'; @@ -54,6 +55,11 @@ export const deleteViewedProduct = async ( config({ data: req }), ); +export const deleteFingerprint = async ( + req: deleteFingerprintResources.Request, +) => + await axios.delete(deleteFingerprintResources.url(), config({ data: req })); + export const forgotPassword = async (req: forgotPasswordResources.Request) => await axios.post(forgotPasswordResources.url(), req); diff --git a/sdk/js/react/src/api/identity/identity/my-account.ts b/sdk/js/react/src/api/identity/identity/my-account.ts index e7e3c13e2..3d4129ce3 100644 --- a/sdk/js/react/src/api/identity/identity/my-account.ts +++ b/sdk/js/react/src/api/identity/identity/my-account.ts @@ -1,4 +1,4 @@ -import { IDENTITY_BASE_PATH, ViewedProduct } from '../common'; +import { IDENTITY_BASE_PATH, ViewedProduct, Fingerprint } from '../common'; export type Response = { id: string; @@ -8,8 +8,9 @@ export type Response = { lastName?: string; email: string; trackViewedProducts: boolean; - viewedProducts: ViewedProduct[]; createdAt: string; + viewedProducts: ViewedProduct[]; + fingerprints: Fingerprint[]; }; export const url = () => `${IDENTITY_BASE_PATH}/my-account`; diff --git a/sdk/js/react/src/api/identity/identity/types.ts b/sdk/js/react/src/api/identity/identity/types.ts index 331d7fb93..521a0d01b 100644 --- a/sdk/js/react/src/api/identity/identity/types.ts +++ b/sdk/js/react/src/api/identity/identity/types.ts @@ -1,4 +1,4 @@ -export type { ViewedProduct } from '../common'; +export type { ViewedProduct, Fingerprint } from '../common'; export type { Response as AuthnResponse } from './authn'; export type { Response as AuthzResponse } from './authz'; export type { Request as ChangeNamesRequest } from './change-names'; diff --git a/sdk/js/react/src/hooks/mutations/identity/identity.ts b/sdk/js/react/src/hooks/mutations/identity/identity.ts index a4a2b5806..4355d3c7d 100644 --- a/sdk/js/react/src/hooks/mutations/identity/identity.ts +++ b/sdk/js/react/src/hooks/mutations/identity/identity.ts @@ -5,6 +5,7 @@ import { Request as ForgotPassword } from '@/api/identity/identity/forgot-passwo import { Request as ResetPassword } from '@/api/identity/identity/reset-password'; import { Request as ChangeNames } from '@/api/identity/identity/change-names'; import { Request as DeleteViewedProduct } from '@/api/identity/identity/delete-viewed-product'; +import { Request as DeleteFingerprint } from '@/api/identity/identity/delete-fingerprint'; import { Request as Register } from '@/api/identity/identity/register'; import { Request as ConfirmEmail } from '@/api/identity/identity/confirm-email'; import { Request as RetryConfirmEmail } from '@/api/identity/identity/retry-confirm-email'; @@ -41,6 +42,11 @@ export const identity = { mutationFn: async (params: DeleteViewedProduct) => (await api.deleteViewedProduct(params)).data, }), + deleteFingerprint: mutationOptions({ + mutationKey: [...BASE_KEY, 'delete-fingerprint'], + mutationFn: async (params: DeleteFingerprint) => + (await api.deleteFingerprint(params)).data, + }), forgotPassword: mutationOptions({ mutationKey: [...BASE_KEY, 'forgot-password'], mutationFn: async (params: ForgotPassword) => From d4b4dda1de8fe98eb52fe3cb3afbb7706e5234d9 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Wed, 13 May 2026 13:24:58 +0300 Subject: [PATCH 18/22] Fixed Build & UnitTest errors --- .../Delete/DeleteAccountHandlerUnitTests.cs | 2 +- .../ProductViewedHandlerUnitTests.cs | 16 ++++++++++++--- .../Login/LoginUserHandlerUnitTests.cs | 20 ++++++++++++------- .../Logout/LogoutUserHandlerUnitTests.cs | 2 +- .../Refresh/RefreshUserHandlerUnitTests.cs | 19 +++++++----------- .../Internal/SSO/SingleSignOnUserUnitTests.cs | 17 ++++++++++------ .../VerifyUserEmailHandlerUnitTests.cs | 10 +++++----- .../GetUserByUsernameHandlerUnitTests.cs | 14 +++++++------ tests/UnitTests/Identity/Data/UsersData.cs | 3 +++ .../UserAddRefreshTokenUnitTests.cs | 6 +++--- .../UserRemoveRefreshTokenUnitTests.cs | 2 +- 11 files changed, 66 insertions(+), 45 deletions(-) diff --git a/tests/UnitTests/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandlerUnitTests.cs b/tests/UnitTests/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandlerUnitTests.cs index 3ae1d6711..3d902cb21 100644 --- a/tests/UnitTests/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandlerUnitTests.cs +++ b/tests/UnitTests/Accounts/Application/Accounts/Commands/Internal/Delete/DeleteAccountHandlerUnitTests.cs @@ -64,7 +64,7 @@ public async Task Handle_ShouldRaiseEvents() // Assert raiser.Verify(x => x.RaiseApplicationEventAsync( - It.Is(x => x.Username == ValidUsername) + It.Is(x => x.Id == ValidId) ), Times.Once()); } diff --git a/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs b/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs index c3c5f4636..66eef43e5 100644 --- a/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs +++ b/tests/UnitTests/Catalog/Application/Products/Events/Application/ProductViewedHandlerUnitTests.cs @@ -146,10 +146,12 @@ public async Task Handle_ShouldReturnEarly_WhenUserDoesNotTrackViewedProducts() await handler.HandleAsync(ae); // Assert - sender.Verify(x => x.SendQueryAsync( - It.Is(x => x.Id == ValidCreatorId && x.ProductId == ValidId), - ct + reads.Verify(x => x.SingleByIdAsync(ValidId, true, ct), Times.Never()); + uow.Verify(x => x.SaveChangesAsync(ct), Times.Never()); + raiser.Verify(x => x.RaiseApplicationEventAsync( + It.Is(x => x.Id == ValidId && x.AccountId == ValidCreatorId) ), Times.Never()); + Assert.Equal(0, product.Counts.Views); } [Fact] @@ -166,6 +168,14 @@ public async Task Handle_ShouldReturnEarly_WhenUserAlreadyViewedProduct() await handler.HandleAsync(ae); // Assert + sender.Verify(x => x.SendQueryAsync( + It.Is(x => x.Username == Username), + ct + ), Times.Never()); + + reads.Verify(x => x.SingleByIdAsync(ValidId, true, ct), Times.Never()); + uow.Verify(x => x.SaveChangesAsync(ct), Times.Never()); + raiser.Verify(x => x.RaiseApplicationEventAsync( It.Is(x => x.Id == ValidId && x.AccountId == ValidCreatorId) ), Times.Never()); diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Login/LoginUserHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Login/LoginUserHandlerUnitTests.cs index 31d85d0c6..1f456ee8a 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Login/LoginUserHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Login/LoginUserHandlerUnitTests.cs @@ -15,7 +15,7 @@ public class LoginUserHandlerUnitTests : UsersBaseUnitTests private readonly Mock tokenService = new(); private readonly User User = CreateUser(username: MaxValidUsername); - private static readonly RefreshToken RefreshToken = RefreshToken.Create("refresh-token", ValidId, false); + private static readonly RefreshToken RefreshToken = RefreshToken.Create("refresh-token", ValidFingerprint, ValidId, false); private static readonly TokensDto Tokens = new( Role: "role", AccessToken: new("access-token", DateTimeOffset.UtcNow), @@ -43,7 +43,8 @@ public async Task Handle_ShouldCallService() LoginUserCommand command = new( Username: User.Username, Password: MinValidPassword, - LongerExpireTime: false + LongerExpireTime: false, + Fingerprint: ValidFingerprint ); // Act @@ -62,7 +63,8 @@ public async Task Handle_ShouldIssueTokens() LoginUserCommand command = new( Username: User.Username, Password: MinValidPassword, - LongerExpireTime: false + LongerExpireTime: false, + Fingerprint: ValidFingerprint ); // Act @@ -82,7 +84,8 @@ public async Task Handle_ShouldReturnResult() LoginUserCommand command = new( Username: User.Username, Password: MinValidPassword, - LongerExpireTime: false + LongerExpireTime: false, + Fingerprint: ValidFingerprint ); // Act @@ -101,7 +104,8 @@ public async Task Handle_ShouldThrowException_WhenPasswordIncorrect() LoginUserCommand command = new( Username: User.Username, Password: MinValidPassword, - LongerExpireTime: false + LongerExpireTime: false, + Fingerprint: ValidFingerprint ); // Assert @@ -120,7 +124,8 @@ public async Task Handle_ShouldThrowException_WhenUserLockedOut() LoginUserCommand command = new( Username: User.Username, Password: MinValidPassword, - LongerExpireTime: false + LongerExpireTime: false, + Fingerprint: ValidFingerprint ); // Assert @@ -140,7 +145,8 @@ public async Task Handle_ShouldThrowException_WhenUserNotVerified() LoginUserCommand command = new( Username: unverifiedUser.Username, Password: MinValidPassword, - LongerExpireTime: false + LongerExpireTime: false, + Fingerprint: ValidFingerprint ); // Assert diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Logout/LogoutUserHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Logout/LogoutUserHandlerUnitTests.cs index fa1dfe610..a933fabea 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Logout/LogoutUserHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Logout/LogoutUserHandlerUnitTests.cs @@ -12,7 +12,7 @@ public class LogoutUserHandlerUnitTests : UsersBaseUnitTests private readonly LogoutUserHandler handler; private readonly Mock service = new(); - private static readonly RefreshToken token = RefreshToken.Create("refresh-token", ValidId, longerSession: false); + private static readonly RefreshToken token = RefreshToken.Create("refresh-token", ValidFingerprint, ValidId, longerSession: false); private readonly User user = CreateUser(username: MaxValidUsername); public LogoutUserHandlerUnitTests() diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandlerUnitTests.cs index 3b6f65012..34baa527f 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/Refresh/RefreshUserHandlerUnitTests.cs @@ -15,7 +15,7 @@ public class RefreshUserHandlerUnitTests : UsersBaseUnitTests private readonly Mock service = new(); private readonly Mock tokenService = new(); - private static readonly RefreshToken RefreshToken = RefreshToken.Create("refresh-token", ValidId, longerSession: false); + private static readonly RefreshToken RefreshToken = RefreshToken.Create("refresh-token", ValidFingerprint, ValidId, longerSession: false); private readonly User User = CreateUser(username: MaxValidUsername); private static readonly TokensDto Tokens = new( Role: "role", @@ -28,9 +28,6 @@ public RefreshUserHandlerUnitTests() { handler = new(service.Object, tokenService.Object); - tokenService.Setup(x => x.IssueRefreshToken( - It.IsAny>() - )).Returns(RefreshToken); tokenService.Setup(x => x.IssueTokens(User, RefreshToken)).Returns(Tokens); service.Setup(x => x.GetByRefreshTokenAsync(RefreshToken.Value)).ReturnsAsync((User, RefreshToken)); @@ -40,7 +37,7 @@ public RefreshUserHandlerUnitTests() public async Task Handle_ShouldCallService() { // Arrange - RefreshUserCommand command = new(RefreshToken.Value); + RefreshUserCommand command = new(RefreshToken.Value, ValidFingerprint); // Act await handler.Handle(command, ct); @@ -53,15 +50,12 @@ public async Task Handle_ShouldCallService() public async Task Handle_ShouldIssueTokens() { // Arrange - RefreshUserCommand command = new(RefreshToken.Value); + RefreshUserCommand command = new(RefreshToken.Value, ValidFingerprint); // Act await handler.Handle(command, ct); // Assert - tokenService.Verify(x => x.IssueRefreshToken( - It.IsAny>() - ), Times.Once()); tokenService.Verify(x => x.IssueTokens(User, RefreshToken), Times.Once()); } @@ -69,7 +63,7 @@ public async Task Handle_ShouldIssueTokens() public async Task Handle_ShouldReturnResult() { // Arrange - RefreshUserCommand command = new(RefreshToken.Value); + RefreshUserCommand command = new(RefreshToken.Value, ValidFingerprint); // Act TokensDto tokens = await handler.Handle(command, ct); @@ -82,7 +76,7 @@ public async Task Handle_ShouldReturnResult() public async Task Handle_ShouldThrowException_WhenMissingToken() { // Arrange - RefreshUserCommand command = new(Token: null); + RefreshUserCommand command = new(Token: null, ValidFingerprint); // Assert await Assert.ThrowsAsync>( @@ -99,13 +93,14 @@ public async Task Handle_ShouldThrowException_WhenTokenExpired() RefreshToken token = RefreshToken.Create( id: RefreshTokenId.New(), value: "refresh-token", + fingerprint: ValidFingerprint, userId: ValidId, issuedAt: yesterday.AddDays(-RtDurationInDays), expiresAt: yesterday ); service.Setup(x => x.GetByRefreshTokenAsync(token.Value)).ReturnsAsync((User, token)); - RefreshUserCommand command = new(token.Value); + RefreshUserCommand command = new(token.Value, ValidFingerprint); // Assert await Assert.ThrowsAsync>( diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/SSO/SingleSignOnUserUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/SSO/SingleSignOnUserUnitTests.cs index 7da4099ed..b28e83d39 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/SSO/SingleSignOnUserUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/SSO/SingleSignOnUserUnitTests.cs @@ -18,7 +18,7 @@ public class SingleSignOnUserUnitTests : UsersBaseUnitTests private const string Provider = "Google"; private readonly User User = CreateUser(username: MaxValidUsername); - private static readonly RefreshToken RefreshToken = RefreshToken.Create("refresh-token", ValidId, false); + private static readonly RefreshToken RefreshToken = RefreshToken.Create("refresh-token", ValidFingerprint, ValidId, false); private static readonly TokensDto Tokens = new( Role: "role", AccessToken: new("access-token", DateTimeOffset.UtcNow), @@ -54,7 +54,8 @@ public async Task Handle_ShouldCallService() LastName: null, Username: User.Username, Email: User.Email.Value, - Provider: Provider + Provider: Provider, + Fingerprint: ValidFingerprint ); // Act @@ -82,7 +83,8 @@ public async Task Handle_ShouldCallGetByEmail_WhenUsernameDoesNotExist() LastName: null, Username: User.Username, Email: User.Email.Value, - Provider: Provider + Provider: Provider, + Fingerprint: ValidFingerprint ); // Act @@ -105,7 +107,8 @@ public async Task Handle_ShouldCallCreateUser_WhenUsernameAndEmailDoNotExist() LastName: null, Username: User.Username, Email: User.Email.Value, - Provider: Provider + Provider: Provider, + Fingerprint: ValidFingerprint ); // Act @@ -140,7 +143,8 @@ public async Task Handle_ShouldIssueTokens() LastName: null, Username: User.Username, Email: User.Email.Value, - Provider: Provider + Provider: Provider, + Fingerprint: ValidFingerprint ); // Act @@ -163,7 +167,8 @@ public async Task Handle_ShouldReturnResult() LastName: null, Username: User.Username, Email: User.Email.Value, - Provider: Provider + Provider: Provider, + Fingerprint: ValidFingerprint ); // Act diff --git a/tests/UnitTests/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandlerUnitTests.cs index 56b878b8a..e562d6cc4 100644 --- a/tests/UnitTests/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Commands/Internal/VerifyEmail/VerifyUserEmailHandlerUnitTests.cs @@ -16,7 +16,7 @@ public class VerifyUserEmailHandlerUnitTests : UsersBaseUnitTests private const string Token = "email-token"; private readonly User User = CreateUser(email: new(ValidEmail, IsVerified: false)); - private static readonly RefreshToken RefreshToken = RefreshToken.Create(Token, ValidId, false); + private static readonly RefreshToken RefreshToken = RefreshToken.Create(Token, ValidFingerprint, ValidId, false); private static readonly TokensDto Tokens = new( Role: "role", AccessToken: new("access-token", DateTimeOffset.UtcNow), @@ -40,7 +40,7 @@ public VerifyUserEmailHandlerUnitTests() public async Task Handle_ShouldCallService() { // Arrange - VerifyUserEmailCommand command = new(User.Username, Token); + VerifyUserEmailCommand command = new(User.Username, Token, ValidFingerprint); // Act await handler.Handle(command, ct); @@ -54,7 +54,7 @@ public async Task Handle_ShouldCallService() public async Task Handle_ShouldIssueTokens() { // Arrange - VerifyUserEmailCommand command = new(User.Username, Token); + VerifyUserEmailCommand command = new(User.Username, Token, ValidFingerprint); // Act await handler.Handle(command, ct); @@ -70,7 +70,7 @@ public async Task Handle_ShouldIssueTokens() public async Task Handle_ShouldReturnResult() { // Arrange - VerifyUserEmailCommand command = new(User.Username, Token); + VerifyUserEmailCommand command = new(User.Username, Token, ValidFingerprint); // Act TokensDto tokens = await handler.Handle(command, ct); @@ -86,7 +86,7 @@ public async Task Handle_ShouldThrowException_WhenEmailVerified() User verifiedUser = CreateUser(email: new(ValidEmail, IsVerified: true)); service.Setup(x => x.GetByUsernameAsync(verifiedUser.Username)).ReturnsAsync(verifiedUser); - VerifyUserEmailCommand command = new(verifiedUser.Username, Token); + VerifyUserEmailCommand command = new(verifiedUser.Username, Token, ValidFingerprint); // Assert await Assert.ThrowsAsync>( diff --git a/tests/UnitTests/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandlerUnitTests.cs b/tests/UnitTests/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandlerUnitTests.cs index 52becb513..b5e5a0288 100644 --- a/tests/UnitTests/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandlerUnitTests.cs +++ b/tests/UnitTests/Identity/Application/Users/Queries/Internal/GetByUsername/GetUserByUsernameHandlerUnitTests.cs @@ -16,13 +16,15 @@ public class GetUserByUsernameHandlerUnitTests : UsersBaseUnitTests public GetUserByUsernameHandlerUnitTests() { + user.AddRefreshToken("refresh-token", ValidFingerprint, false); + handler = new(service.Object, sender.Object); service.Setup(x => x.GetByAccountIdAsync(user.AccountId)) .ReturnsAsync(user); sender.Setup(x => x.SendQueryAsync( - It.Is(x => x.Username == MaxValidUsername), + It.Is(x => x.Username == user.Username), ct )).ReturnsAsync(new AccountInfoDto(ValidAccountId, DateTimeOffset.UtcNow, true, null, null)); } @@ -31,7 +33,7 @@ public GetUserByUsernameHandlerUnitTests() public async Task Handle_ShouldCallService() { // Arrange - GetUserByUsernameQuery query = new(user.AccountId); + GetUserByUsernameQuery query = new(user.AccountId, "refresh-token"); // Act await handler.Handle(query, ct); @@ -44,18 +46,18 @@ public async Task Handle_ShouldCallService() public async Task Handle_ShouldSendRequests() { // Arrange - GetUserByUsernameQuery query = new(user.AccountId); + GetUserByUsernameQuery query = new(user.AccountId, "refresh-token"); // Act await handler.Handle(query, ct); // Assert sender.Verify(x => x.SendQueryAsync( - It.Is(x => x.Username == MaxValidUsername), + It.Is(x => x.Username == user.Username), ct ), Times.Once()); sender.Verify(x => x.SendQueryAsync( - It.Is(x => x.Username == MaxValidUsername), + It.Is(x => x.Username == user.Username), ct ), Times.Once()); } @@ -64,7 +66,7 @@ public async Task Handle_ShouldSendRequests() public async Task Handle_ShouldReturnResult() { // Arrange - GetUserByUsernameQuery query = new(user.AccountId); + GetUserByUsernameQuery query = new(user.AccountId, "refresh-token"); // Act var result = await handler.Handle(query, ct); diff --git a/tests/UnitTests/Identity/Data/UsersData.cs b/tests/UnitTests/Identity/Data/UsersData.cs index 1974327aa..000e8ca91 100644 --- a/tests/UnitTests/Identity/Data/UsersData.cs +++ b/tests/UnitTests/Identity/Data/UsersData.cs @@ -1,4 +1,5 @@ using CustomCADs.Modules.Identity.Domain.Users; +using CustomCADs.Modules.Identity.Domain.Users.ValueObjects; using CustomCADs.Shared.Domain; using CustomCADs.Shared.Domain.TypedIds.Accounts; using CustomCADs.Shared.Domain.TypedIds.Identity; @@ -26,6 +27,8 @@ public static class UsersData public static readonly string InvalidPassword = string.Empty; public static readonly string MinInvalidPassword = new('a', PasswordMinLength - 1); + public static readonly Fingerprint ValidFingerprint = new(); + public static readonly UserId ValidId = UserId.New(); public static readonly AccountId ValidAccountId = AccountId.New(); } diff --git a/tests/UnitTests/Identity/Domain/Users/Behaviors/AddRefreshToken/UserAddRefreshTokenUnitTests.cs b/tests/UnitTests/Identity/Domain/Users/Behaviors/AddRefreshToken/UserAddRefreshTokenUnitTests.cs index f12fffae7..1dff27f7a 100644 --- a/tests/UnitTests/Identity/Domain/Users/Behaviors/AddRefreshToken/UserAddRefreshTokenUnitTests.cs +++ b/tests/UnitTests/Identity/Domain/Users/Behaviors/AddRefreshToken/UserAddRefreshTokenUnitTests.cs @@ -11,7 +11,7 @@ public class UserAddRefreshTokenUnitTests : UsersBaseUnitTests [Fact] public void AddRefreshToken_ShouldNotThrowException() { - user.AddRefreshToken(Value, longerSession: false); + user.AddRefreshToken(Value, new(), longerSession: false); } [Theory] @@ -24,7 +24,7 @@ public void AddRefreshToken_ShouldReturnResult(bool longerSession) : Tokens.RtDurationInDays; TimeSpan expectedDuration = TimeSpan.FromDays(expectedDurationDays); - RefreshToken rt = user.AddRefreshToken(Value, longerSession); + RefreshToken rt = user.AddRefreshToken(Value, new(), longerSession); TimeSpan actualDuration = rt.ExpiresAt - rt.IssuedAt; Assert.Multiple( @@ -37,7 +37,7 @@ public void AddRefreshToken_ShouldReturnResult(bool longerSession) [Fact] public void AddRefreshToken_PopulatesProperty() { - RefreshToken rt = user.AddRefreshToken(Value, longerSession: false); + RefreshToken rt = user.AddRefreshToken(Value, new(), longerSession: false); Assert.Contains(rt, user.RefreshTokens); } } diff --git a/tests/UnitTests/Identity/Domain/Users/Behaviors/RemoveRefreshToken/UserRemoveRefreshTokenUnitTests.cs b/tests/UnitTests/Identity/Domain/Users/Behaviors/RemoveRefreshToken/UserRemoveRefreshTokenUnitTests.cs index cf5095e3c..62c8bfbe1 100644 --- a/tests/UnitTests/Identity/Domain/Users/Behaviors/RemoveRefreshToken/UserRemoveRefreshTokenUnitTests.cs +++ b/tests/UnitTests/Identity/Domain/Users/Behaviors/RemoveRefreshToken/UserRemoveRefreshTokenUnitTests.cs @@ -9,7 +9,7 @@ public class UserRemoveRefreshTokenUnitTests : UsersBaseUnitTests public UserRemoveRefreshTokenUnitTests() { - rt = user.AddRefreshToken("refresh-token", longerSession: false); + rt = user.AddRefreshToken("refresh-token", new(), longerSession: false); } [Fact] From 4be46211353ad39b25acfbae9dceeef4f9ca6faa Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Wed, 13 May 2026 13:31:41 +0300 Subject: [PATCH 19/22] Empty Body Sanitization fix --- src/Presentation/ProgramExtensions.cs | 17 ++++++++++++++++- src/Shared/API/Extensions/HttpExtensions.cs | 4 ++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Presentation/ProgramExtensions.cs b/src/Presentation/ProgramExtensions.cs index e4906fd97..89da98437 100644 --- a/src/Presentation/ProgramExtensions.cs +++ b/src/Presentation/ProgramExtensions.cs @@ -178,7 +178,7 @@ public IApplicationBuilder UseEndpoints() cfg.Versioning.PrependToRoute = true; cfg.Errors.ResponseBuilder = (failures, _, _) => throw failures.ValidationException; - }); + }).UseEmptyBodySanitization(); public IApplicationBuilder UseDisableBrowserCaching() => app.Use(async (context, next) => @@ -193,6 +193,21 @@ public IApplicationBuilder UseDisableBrowserCaching() await next().ConfigureAwait(false); }); + + private IApplicationBuilder UseEmptyBodySanitization() + { + app.Use(async (context, next) => + { + if (context.Request.IsGetWithEmptyBody) + { + context.Request.ContentType = null; + } + + await next().ConfigureAwait(false); + }); + + return app; + } } extension(IEndpointRouteBuilder router) diff --git a/src/Shared/API/Extensions/HttpExtensions.cs b/src/Shared/API/Extensions/HttpExtensions.cs index 8a52ad3c1..2b544db6f 100644 --- a/src/Shared/API/Extensions/HttpExtensions.cs +++ b/src/Shared/API/Extensions/HttpExtensions.cs @@ -8,6 +8,10 @@ public static class HttpExtensions { public bool IsSignalR => request.Path.StartsWithSegments($"/{APIConstants.RequestPrefixForSignalR}"); + public bool IsGetWithEmptyBody => + HttpMethods.IsGet(request.Method) + && request is { ContentLength: null or 0 }; + public bool IsIdempotentBySpec => HttpMethods.IsGet(request.Method) || HttpMethods.IsPut(request.Method) From 033c0c94bd417dfb9d46eade61142bf8367a5c45 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Wed, 13 May 2026 13:34:05 +0300 Subject: [PATCH 20/22] Docker Bake typo --- docker-bake.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-bake.hcl b/docker-bake.hcl index 722bb3840..54f0618ce 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -46,7 +46,7 @@ target "awsecr_staging" { tags = ["${awsecr_repostory}:staging"] } -# Printing +# Production target "production" { context = "." dockerfile = "Dockerfile.production" From 4bf6fb459cb5484faa047c632c12bf579886da9e Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Wed, 13 May 2026 13:35:28 +0300 Subject: [PATCH 21/22] Tiny technical refactorings --- .../Commands/Internal/Cancel/CancelShipmentHandler.cs | 8 ++++---- src/Modules/Files/Domain/Repositories/Reads/ImageQuery.cs | 8 -------- .../Internal/Cancel/CancelShipmentHandlerUnitTests.cs | 3 ++- 3 files changed, 6 insertions(+), 13 deletions(-) delete mode 100644 src/Modules/Files/Domain/Repositories/Reads/ImageQuery.cs diff --git a/src/Modules/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandler.cs b/src/Modules/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandler.cs index 635691ef4..fb73eac62 100644 --- a/src/Modules/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandler.cs +++ b/src/Modules/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandler.cs @@ -15,19 +15,19 @@ public async Task Handle(CancelShipmentCommand req, CancellationToken ct) { Shipment shipment = await reads.SingleByIdAsync(req.Id, track: false, ct).ConfigureAwait(false) ?? throw CustomNotFoundException.ById(req.Id); + shipment.Cancel(); - if (shipment is not { Status: ShipmentStatus.Active, Reference.Id: not null }) + if (shipment is not { Reference.Id: not null }) { throw CustomStatusException.ById(req.Id); } - shipment.Cancel(); - await uow.SaveChangesAsync(ct).ConfigureAwait(false); - await delivery.CancelAsync( shipmentId: shipment.Reference.Id, comment: req.Comment, ct: ct ).ConfigureAwait(false); + + await uow.SaveChangesAsync(ct).ConfigureAwait(false); } } diff --git a/src/Modules/Files/Domain/Repositories/Reads/ImageQuery.cs b/src/Modules/Files/Domain/Repositories/Reads/ImageQuery.cs deleted file mode 100644 index e868fc6d0..000000000 --- a/src/Modules/Files/Domain/Repositories/Reads/ImageQuery.cs +++ /dev/null @@ -1,8 +0,0 @@ -using CustomCADs.Shared.Domain.Querying; - -namespace CustomCADs.Modules.Files.Domain.Repositories.Reads; - -public record ImageQuery( - Pagination Pagination, - ImageId[]? Ids = null -); diff --git a/tests/UnitTests/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandlerUnitTests.cs b/tests/UnitTests/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandlerUnitTests.cs index b7d5db5d1..5334faf16 100644 --- a/tests/UnitTests/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandlerUnitTests.cs +++ b/tests/UnitTests/Delivery/Application/Shipments/Commands/Internal/Cancel/CancelShipmentHandlerUnitTests.cs @@ -3,6 +3,7 @@ using CustomCADs.Modules.Delivery.Domain.Repositories; using CustomCADs.Modules.Delivery.Domain.Repositories.Reads; using CustomCADs.Shared.Application.Exceptions; +using CustomCADs.Shared.Domain.Exceptions; namespace CustomCADs.UnitTests.Delivery.Application.Shipments.Commands.Internal.Cancel; @@ -81,7 +82,7 @@ public async Task Handle_ShouldThrowException_WhenShipmentStatusInvalid() CancelShipmentCommand command = new(ValidId, Comment); // Assert - await Assert.ThrowsAsync>( + await Assert.ThrowsAsync>( // Act async () => await handler.Handle(command, ct) ); From d11955a97ccb69d0db05496f0406e4feec337e8a Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Wed, 13 May 2026 13:37:58 +0300 Subject: [PATCH 22/22] General SDK improvements --- .../src/api/common/exchange-rates/common.ts | 2 +- sdk/js/react/src/constants.ts | 76 +++++++++---------- sdk/js/react/src/hooks/index.ts | 1 + sdk/js/react/src/hooks/queries/queryCall.ts | 15 ++++ sdk/js/react/src/index.ts | 1 - sdk/js/react/src/types.ts | 13 ---- 6 files changed, 52 insertions(+), 56 deletions(-) create mode 100644 sdk/js/react/src/hooks/queries/queryCall.ts delete mode 100644 sdk/js/react/src/types.ts diff --git a/sdk/js/react/src/api/common/exchange-rates/common.ts b/sdk/js/react/src/api/common/exchange-rates/common.ts index f0b79a9b3..d4fe0c3ae 100644 --- a/sdk/js/react/src/api/common/exchange-rates/common.ts +++ b/sdk/js/react/src/api/common/exchange-rates/common.ts @@ -1,4 +1,4 @@ -import { Currency } from '@/types'; +import { Currency } from '@/constants'; export type ExchangeRate = { date: string; diff --git a/sdk/js/react/src/constants.ts b/sdk/js/react/src/constants.ts index e8554b571..ecfb4e2e6 100644 --- a/sdk/js/react/src/constants.ts +++ b/sdk/js/react/src/constants.ts @@ -1,45 +1,39 @@ -import { Currency, Rates, Symbols } from './types'; - export const EXCHANGE_RATES = { - EUR: { rate: 1, symbol: '€', language: '' }, - USD: { rate: 1.1646, symbol: '$', language: 'en-US' }, - JPY: { rate: 173.1, symbol: '¥', language: 'ja-JP' }, - BGN: { rate: 1.9558, symbol: 'лв', language: 'bg-BG' }, - CZK: { rate: 24.485, symbol: 'Kč', language: 'cs-CZ' }, - DKK: { rate: 7.4632, symbol: 'kr', language: 'da-DK' }, - GBP: { rate: 0.8702, symbol: '£', language: 'en-GB' }, - HUF: { rate: 395.58, symbol: 'Ft', language: 'hu-HU' }, - PLN: { rate: 4.2653, symbol: 'zł', language: 'pl-PL' }, - RON: { rate: 5.0822, symbol: 'lei', language: 'ro-RO' }, - SEK: { rate: 11.003, symbol: 'kr', language: 'sv-SE' }, - CHF: { rate: 0.9366, symbol: 'CHF', language: 'fr-CH' }, - ISK: { rate: 143.6, symbol: 'Íkr', language: 'is-IS' }, - NOK: { rate: 11.674, symbol: 'kr', language: 'no-NO' }, - TRY: { rate: 47.9289, symbol: '₺', language: 'tr-TR' }, - AUD: { rate: 1.7897, symbol: 'A$', language: 'en-AU' }, - BRL: { rate: 6.3757, symbol: 'R$', language: 'pt-BR' }, - CAD: { rate: 1.6056, symbol: 'CA$', language: 'en-CA' }, - CNY: { rate: 8.3202, symbol: '¥', language: 'zh-CN' }, - HKD: { rate: 9.0915, symbol: 'HK$', language: 'zh-HK' }, - IDR: { rate: 19110.27, symbol: 'Rp', language: 'id-ID' }, - ILS: { rate: 3.9478, symbol: '₪', language: 'he-IL' }, - INR: { rate: 102.5795, symbol: '₹', language: 'hi-IN' }, - KRW: { rate: 1625.01, symbol: '₩', language: 'ko-KR' }, - MXN: { rate: 21.8726, symbol: '$', language: 'es-MX' }, - MYR: { rate: 4.9263, symbol: 'RM', language: 'ms-MY' }, - NZD: { rate: 1.9892, symbol: '$', language: 'en-NZ' }, - PHP: { rate: 66.762, symbol: '₱', language: 'fil-PH' }, - SGD: { rate: 1.5006, symbol: 'S$', language: 'en-SG' }, - THB: { rate: 37.704, symbol: '฿', language: 'th-TH' }, - ZAR: { rate: 20.6439, symbol: 'R', language: 'en-ZA' }, + EUR: { rate: 1, symbol: '€', language: '', display: true }, + USD: { rate: 1.1646, symbol: '$', language: 'en-US', display: true }, + JPY: { rate: 173.1, symbol: '¥', language: 'ja-JP', display: true }, + BGN: { rate: 1.9558, symbol: 'лв', language: 'bg-BG', display: false }, + CZK: { rate: 24.485, symbol: 'Kč', language: 'cs-CZ', display: true }, + DKK: { rate: 7.4632, symbol: 'kr', language: 'da-DK', display: true }, + GBP: { rate: 0.8702, symbol: '£', language: 'en-GB', display: true }, + HUF: { rate: 395.58, symbol: 'Ft', language: 'hu-HU', display: true }, + PLN: { rate: 4.2653, symbol: 'zł', language: 'pl-PL', display: true }, + RON: { rate: 5.0822, symbol: 'lei', language: 'ro-RO', display: true }, + SEK: { rate: 11.003, symbol: 'kr', language: 'sv-SE', display: true }, + CHF: { rate: 0.9366, symbol: 'CHF', language: 'fr-CH', display: true }, + ISK: { rate: 143.6, symbol: 'Íkr', language: 'is-IS', display: true }, + NOK: { rate: 11.674, symbol: 'kr', language: 'no-NO', display: true }, + TRY: { rate: 47.9289, symbol: '₺', language: 'tr-TR', display: true }, + AUD: { rate: 1.7897, symbol: 'A$', language: 'en-AU', display: true }, + BRL: { rate: 6.3757, symbol: 'R$', language: 'pt-BR', display: true }, + CAD: { rate: 1.6056, symbol: 'CA$', language: 'en-CA', display: true }, + CNY: { rate: 8.3202, symbol: '¥', language: 'zh-CN', display: true }, + HKD: { rate: 9.0915, symbol: 'HK$', language: 'zh-HK', display: true }, + IDR: { rate: 19110.27, symbol: 'Rp', language: 'id-ID', display: true }, + ILS: { rate: 3.9478, symbol: '₪', language: 'he-IL', display: true }, + INR: { rate: 102.5795, symbol: '₹', language: 'hi-IN', display: true }, + KRW: { rate: 1625.01, symbol: '₩', language: 'ko-KR', display: true }, + MXN: { rate: 21.8726, symbol: '$', language: 'es-MX', display: true }, + MYR: { rate: 4.9263, symbol: 'RM', language: 'ms-MY', display: true }, + NZD: { rate: 1.9892, symbol: '$', language: 'en-NZ', display: true }, + PHP: { rate: 66.762, symbol: '₱', language: 'fil-PH', display: true }, + SGD: { rate: 1.5006, symbol: 'S$', language: 'en-SG', display: true }, + THB: { rate: 37.704, symbol: '฿', language: 'th-TH', display: true }, + ZAR: { rate: 20.6439, symbol: 'R', language: 'en-ZA', display: true }, } as const; -export const CURRENCIES = Object.keys(EXCHANGE_RATES) as Currency[]; - -export const RATES: Rates = Object.fromEntries( - Object.entries(EXCHANGE_RATES).map(([k, v]) => [k, v.rate]), -) as never; +export type Currency = keyof typeof EXCHANGE_RATES; -export const SYMBOLS: Symbols = Object.fromEntries( - Object.entries(EXCHANGE_RATES).map(([k, v]) => [k, v.symbol]), -) as never; +export const CURRENCIES = Object.entries(EXCHANGE_RATES) + .filter(([, v]) => v.display) + .map(([k]) => k) as Array; diff --git a/sdk/js/react/src/hooks/index.ts b/sdk/js/react/src/hooks/index.ts index fb25256c2..9bcfb68a8 100644 --- a/sdk/js/react/src/hooks/index.ts +++ b/sdk/js/react/src/hooks/index.ts @@ -1,5 +1,6 @@ export * as queries from './queries'; export { useQuery } from './queries/useQuery'; export { useInfiniteQuery } from './queries/useInfiniteQuery'; +export { queryCall } from './queries/queryCall'; export * as mutations from './mutations'; export { useMutation } from './mutations/useMutation'; diff --git a/sdk/js/react/src/hooks/queries/queryCall.ts b/sdk/js/react/src/hooks/queries/queryCall.ts new file mode 100644 index 000000000..e19860ffe --- /dev/null +++ b/sdk/js/react/src/hooks/queries/queryCall.ts @@ -0,0 +1,15 @@ +import { AxiosResponse } from 'axios'; +import { FetchQueryOptions } from '@tanstack/react-query'; +import * as customcadsQueries from '.'; + +type Opts = FetchQueryOptions< + AxiosResponse, + Error, + AxiosResponse, + TKey +>; + +export const queryCall = ( + optionsSelector: (queries: typeof customcadsQueries) => Opts, + call: (options: Opts) => Promise | void>, +) => call(optionsSelector(customcadsQueries)); diff --git a/sdk/js/react/src/index.ts b/sdk/js/react/src/index.ts index ecae80f51..fa7ff4ce8 100644 --- a/sdk/js/react/src/index.ts +++ b/sdk/js/react/src/index.ts @@ -1,4 +1,3 @@ export * from './constants'; -export type { Currency, Rate, Symbol } from './types'; export * from './api'; export * from './hooks'; diff --git a/sdk/js/react/src/types.ts b/sdk/js/react/src/types.ts deleted file mode 100644 index 697d2f5ad..000000000 --- a/sdk/js/react/src/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { EXCHANGE_RATES } from './constants'; - -export type Currency = keyof typeof EXCHANGE_RATES; - -export type Rates = { - [C in Currency]: (typeof EXCHANGE_RATES)[C]['rate']; -}; -export type Rate = Rates[Currency]; - -export type Symbols = { - [C in Currency]: (typeof EXCHANGE_RATES)[C]['symbol']; -}; -export type Symbol = Symbols[Currency];