Skip to content

Commit 447481a

Browse files
iammukeshmjarvis
andauthored
refactor(identity): split UserService into single-responsibility services (#1174) (#1192)
Split the UserService god class into focused, single-responsibility services: **New Interfaces (Modules.Identity.Contracts/Services/):** - IUserRegistrationService: User registration and external auth - IUserProfileService: Profile CRUD operations - IUserStatusService: Status toggle and delete operations - IUserRoleService: Role assignment operations - IUserPasswordService: Password operations (forgot/reset/change) - IUserPermissionService: Permission queries and caching **New Implementations (Modules.Identity/Services/):** - UserRegistrationService - UserProfileService - UserStatusService - UserRoleService - UserPasswordService - UserPermissionService **Other Changes:** - UserService now acts as a facade delegating to the new services - Updated DI registration in IdentityModule.cs - Deleted 7 old partial class files This improves: - Single Responsibility Principle compliance - Testability (smaller focused services) - Maintainability (clear separation of concerns) - Reusability (handlers can inject specific services they need) Closes #1174 Co-authored-by: jarvis <jarvis@codewithmukesh.com>
1 parent 42b977a commit 447481a

15 files changed

Lines changed: 495 additions & 187 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace FSH.Modules.Identity.Contracts.Services;
2+
3+
/// <summary>
4+
/// Service for user password operations.
5+
/// </summary>
6+
public interface IUserPasswordService
7+
{
8+
/// <summary>
9+
/// Initiates the forgot password flow by sending a reset email.
10+
/// </summary>
11+
Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken);
12+
13+
/// <summary>
14+
/// Resets a user's password using a token.
15+
/// </summary>
16+
Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken);
17+
18+
/// <summary>
19+
/// Changes the current user's password.
20+
/// </summary>
21+
Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId);
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace FSH.Modules.Identity.Contracts.Services;
2+
3+
/// <summary>
4+
/// Service for user permission operations.
5+
/// </summary>
6+
public interface IUserPermissionService
7+
{
8+
/// <summary>
9+
/// Gets all permissions for a user.
10+
/// </summary>
11+
Task<List<string>?> GetPermissionsAsync(string userId, CancellationToken cancellationToken);
12+
13+
/// <summary>
14+
/// Checks if a user has a specific permission.
15+
/// </summary>
16+
Task<bool> HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default);
17+
18+
/// <summary>
19+
/// Invalidates the permission cache for a user.
20+
/// </summary>
21+
Task InvalidatePermissionCacheAsync(string userId, CancellationToken cancellationToken);
22+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using FSH.Framework.Shared.Storage;
2+
using FSH.Modules.Identity.Contracts.DTOs;
3+
4+
namespace FSH.Modules.Identity.Contracts.Services;
5+
6+
/// <summary>
7+
/// Service for user profile operations.
8+
/// </summary>
9+
public interface IUserProfileService
10+
{
11+
/// <summary>
12+
/// Gets a user by ID.
13+
/// </summary>
14+
Task<UserDto> GetAsync(string userId, CancellationToken cancellationToken);
15+
16+
/// <summary>
17+
/// Gets all users.
18+
/// </summary>
19+
Task<List<UserDto>> GetListAsync(CancellationToken cancellationToken);
20+
21+
/// <summary>
22+
/// Gets the total user count.
23+
/// </summary>
24+
Task<int> GetCountAsync(CancellationToken cancellationToken);
25+
26+
/// <summary>
27+
/// Updates a user's profile.
28+
/// </summary>
29+
Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage);
30+
31+
/// <summary>
32+
/// Checks if a user exists with the given email.
33+
/// </summary>
34+
Task<bool> ExistsWithEmailAsync(string email, string? exceptId = null);
35+
36+
/// <summary>
37+
/// Checks if a user exists with the given username.
38+
/// </summary>
39+
Task<bool> ExistsWithNameAsync(string name);
40+
41+
/// <summary>
42+
/// Checks if a user exists with the given phone number.
43+
/// </summary>
44+
Task<bool> ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null);
45+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Security.Claims;
2+
3+
namespace FSH.Modules.Identity.Contracts.Services;
4+
5+
/// <summary>
6+
/// Service for user registration and external authentication.
7+
/// </summary>
8+
public interface IUserRegistrationService
9+
{
10+
/// <summary>
11+
/// Registers a new user with password.
12+
/// </summary>
13+
Task<string> RegisterAsync(
14+
string firstName,
15+
string lastName,
16+
string email,
17+
string userName,
18+
string password,
19+
string confirmPassword,
20+
string phoneNumber,
21+
string origin,
22+
CancellationToken cancellationToken);
23+
24+
/// <summary>
25+
/// Gets or creates a user from an external authentication principal.
26+
/// </summary>
27+
Task<string> GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal);
28+
29+
/// <summary>
30+
/// Confirms a user's email address.
31+
/// </summary>
32+
Task<string> ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken);
33+
34+
/// <summary>
35+
/// Confirms a user's phone number.
36+
/// </summary>
37+
Task<string> ConfirmPhoneNumberAsync(string userId, string code);
38+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using FSH.Modules.Identity.Contracts.DTOs;
2+
3+
namespace FSH.Modules.Identity.Contracts.Services;
4+
5+
/// <summary>
6+
/// Service for user role management.
7+
/// </summary>
8+
public interface IUserRoleService
9+
{
10+
/// <summary>
11+
/// Assigns roles to a user.
12+
/// </summary>
13+
Task<string> AssignRolesAsync(string userId, List<UserRoleDto> userRoles, CancellationToken cancellationToken);
14+
15+
/// <summary>
16+
/// Gets all roles for a user.
17+
/// </summary>
18+
Task<List<UserRoleDto>> GetUserRolesAsync(string userId, CancellationToken cancellationToken);
19+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace FSH.Modules.Identity.Contracts.Services;
2+
3+
/// <summary>
4+
/// Service for user status and lifecycle operations.
5+
/// </summary>
6+
public interface IUserStatusService
7+
{
8+
/// <summary>
9+
/// Toggles a user's active status.
10+
/// </summary>
11+
Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken);
12+
13+
/// <summary>
14+
/// Soft-deletes a user by deactivating them.
15+
/// </summary>
16+
Task DeleteAsync(string userId);
17+
}

src/Modules/Identity/Modules.Identity/IdentityModule.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,18 @@ public void ConfigureServices(IHostApplicationBuilder builder)
7777
services.AddScoped<IRequestContext, RequestContextService>();
7878
services.AddScoped<ITokenService, TokenService>();
7979
services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService<ICurrentUser>());
80+
81+
// User services - focused single-responsibility services
82+
services.AddTransient<IUserRegistrationService, UserRegistrationService>();
83+
services.AddTransient<IUserProfileService, UserProfileService>();
84+
services.AddTransient<IUserStatusService, UserStatusService>();
85+
services.AddTransient<IUserRoleService, UserRoleService>();
86+
services.AddTransient<IUserPasswordService, UserPasswordService>();
87+
services.AddTransient<IUserPermissionService, UserPermissionService>();
88+
89+
// Facade for backward compatibility
8090
services.AddTransient<IUserService, UserService>();
91+
8192
services.AddTransient<IRoleService, RoleService>();
8293
services.AddHeroStorage(builder.Configuration);
8394
services.AddScoped<IIdentityService, IdentityService>();

src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs renamed to src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
1-
using FSH.Framework.Core.Exceptions;
1+
using Finbuckle.MultiTenant.Abstractions;
2+
using FSH.Framework.Core.Exceptions;
3+
using FSH.Framework.Jobs.Services;
24
using FSH.Framework.Mailing;
5+
using FSH.Framework.Mailing.Services;
6+
using FSH.Framework.Shared.Multitenancy;
7+
using FSH.Modules.Identity.Contracts.Services;
8+
using FSH.Modules.Identity.Data;
9+
using FSH.Modules.Identity.Domain;
10+
using Microsoft.AspNetCore.Identity;
311
using Microsoft.AspNetCore.WebUtilities;
412
using System.Collections.ObjectModel;
513
using System.Text;
614

715
namespace FSH.Modules.Identity.Services;
816

9-
internal sealed partial class UserService
17+
internal sealed class UserPasswordService(
18+
UserManager<FshUser> userManager,
19+
IdentityDbContext db,
20+
IJobService jobService,
21+
IMailService mailService,
22+
IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor,
23+
IPasswordHistoryService passwordHistoryService,
24+
IPasswordExpiryService passwordExpiryService) : IUserPasswordService
1025
{
1126
public async Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken)
1227
{
@@ -80,9 +95,17 @@ public async Task ChangePasswordAsync(string password, string newPassword, strin
8095
await db.SaveChangesAsync();
8196

8297
// Update password expiry date
83-
await _passwordExpiryService.UpdateLastPasswordChangeDateAsync(userId);
98+
await passwordExpiryService.UpdateLastPasswordChangeDateAsync(userId);
8499

85100
// Save to history
86-
await _passwordHistoryService.SavePasswordHistoryAsync(userId);
101+
await passwordHistoryService.SavePasswordHistoryAsync(userId);
87102
}
88-
}
103+
104+
private void EnsureValidTenant()
105+
{
106+
if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id))
107+
{
108+
throw new UnauthorizedException("invalid tenant");
109+
}
110+
}
111+
}

src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs renamed to src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
using FSH.Framework.Caching;
1+
using FSH.Framework.Caching;
22
using FSH.Framework.Core.Exceptions;
33
using FSH.Framework.Shared.Constants;
4+
using FSH.Modules.Identity.Contracts.Services;
5+
using FSH.Modules.Identity.Data;
6+
using FSH.Modules.Identity.Domain;
7+
using Microsoft.AspNetCore.Identity;
48
using Microsoft.EntityFrameworkCore;
59

610
namespace FSH.Modules.Identity.Services;
711

8-
internal sealed partial class UserService
12+
internal sealed class UserPermissionService(
13+
UserManager<FshUser> userManager,
14+
RoleManager<FshRole> roleManager,
15+
IdentityDbContext db,
16+
ICacheService cache) : IUserPermissionService
917
{
1018
public async Task<List<string>?> GetPermissionsAsync(string userId, CancellationToken cancellationToken)
1119
{
@@ -51,4 +59,4 @@ public Task InvalidatePermissionCacheAsync(string userId, CancellationToken canc
5159
{
5260
return cache.RemoveItemAsync(GetPermissionCacheKey(userId), cancellationToken);
5361
}
54-
}
62+
}

src/Modules/Identity/Modules.Identity/Services/UserService.Profile.cs renamed to src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,30 @@
1+
using Finbuckle.MultiTenant.Abstractions;
12
using FSH.Framework.Core.Exceptions;
3+
using FSH.Framework.Shared.Multitenancy;
24
using FSH.Framework.Shared.Storage;
35
using FSH.Framework.Storage;
6+
using FSH.Framework.Storage.Services;
7+
using FSH.Framework.Web.Origin;
48
using FSH.Modules.Identity.Contracts.DTOs;
9+
using FSH.Modules.Identity.Contracts.Services;
510
using FSH.Modules.Identity.Domain;
11+
using Microsoft.AspNetCore.Http;
12+
using Microsoft.AspNetCore.Identity;
613
using Microsoft.EntityFrameworkCore;
14+
using Microsoft.Extensions.Options;
715

816
namespace FSH.Modules.Identity.Services;
917

10-
internal sealed partial class UserService
18+
internal sealed class UserProfileService(
19+
UserManager<FshUser> userManager,
20+
SignInManager<FshUser> signInManager,
21+
IStorageService storageService,
22+
IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor,
23+
IOptions<OriginOptions> originOptions,
24+
IHttpContextAccessor httpContextAccessor) : IUserProfileService
1125
{
26+
private readonly Uri? _originUrl = originOptions.Value.OriginUrl;
27+
1228
public async Task<UserDto> GetAsync(string userId, CancellationToken cancellationToken)
1329
{
1430
var user = await userManager.Users
@@ -105,4 +121,43 @@ public async Task<bool> ExistsWithPhoneNumberAsync(string phoneNumber, string? e
105121
EnsureValidTenant();
106122
return await userManager.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber) is FshUser user && user.Id != exceptId;
107123
}
124+
125+
private void EnsureValidTenant()
126+
{
127+
if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id))
128+
{
129+
throw new UnauthorizedException("invalid tenant");
130+
}
131+
}
132+
133+
private string? ResolveImageUrl(Uri? imageUrl)
134+
{
135+
if (imageUrl is null)
136+
{
137+
return null;
138+
}
139+
140+
// Absolute URLs (e.g., S3) pass through unchanged.
141+
if (imageUrl.IsAbsoluteUri)
142+
{
143+
return imageUrl.ToString();
144+
}
145+
146+
// For relative paths from local storage, prefix with the API origin and wwwroot.
147+
if (_originUrl is null)
148+
{
149+
var request = httpContextAccessor.HttpContext?.Request;
150+
if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue)
151+
{
152+
var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}";
153+
var relativePath = imageUrl.ToString().TrimStart('/');
154+
return $"{baseUri.TrimEnd('/')}/{relativePath}";
155+
}
156+
157+
return imageUrl.ToString();
158+
}
159+
160+
var originRelativePath = imageUrl.ToString().TrimStart('/');
161+
return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}";
162+
}
108163
}

0 commit comments

Comments
 (0)