using System.Text.Json; using AMREZ.EOP.Abstractions.Applications.Tenancy; using AMREZ.EOP.Abstractions.Infrastructures.Repositories; using AMREZ.EOP.Abstractions.Storage; using AMREZ.EOP.Domain.Entities.Authentications; using AMREZ.EOP.Domain.Shared._Users; using AMREZ.EOP.Infrastructures.Data; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; namespace AMREZ.EOP.Infrastructures.Repositories; public class UserRepository : IUserRepository { private readonly IDbScope _scope; private readonly IConnectionMultiplexer? _redis; private readonly ITenantResolver _tenantResolver; private readonly IHttpContextAccessor _http; public UserRepository( IDbScope scope, IConnectionMultiplexer? redis, ITenantResolver tenantResolver, IHttpContextAccessor http) { _scope = scope; _redis = redis; // null = ไม่มี/ต่อไม่ได้ → ข้าม cache _tenantResolver = tenantResolver; _http = http; } private Guid TenantId() { var http = _http.HttpContext; var tc = http is not null ? _tenantResolver.Resolve(http) : null; return Guid.TryParse(tc?.Id, out var g) ? g : Guid.Empty; } private static string IdentityEmailKey(string tenantId, string email) => $"eop:{tenantId}:user:identity:email:{email.Trim().ToLowerInvariant()}"; private static string UserIdKey(string tenantId, Guid id) => $"eop:{tenantId}:user:id:{id:N}"; public async Task FindByIdAsync(Guid userId, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); return await db.Users.AsNoTracking() .FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct); } public async Task FindActiveByEmailAsync(string email, CancellationToken ct = default) { var tid = TenantId(); var tidStr = tid.ToString(); var r = _redis?.GetDatabase(); // 1) Redis if (r is not null) { try { var cached = await r.StringGetAsync(IdentityEmailKey(tidStr, email)); if (cached.HasValue) { var hit = JsonSerializer.Deserialize(cached!); if (hit?.IsActive == true) return hit; } } catch { /* ignore → query DB */ } } // 2) EF: join identities var db = _scope.Get(); var norm = email.Trim().ToLowerInvariant(); var user = await db.Users .AsNoTracking() .Where(u => u.TenantId == tid && u.IsActive) .Where(u => db.UserIdentities.Any(i => i.TenantId == tid && i.UserId == u.Id && i.Type == IdentityType.Email && i.Identifier == norm)) .FirstOrDefaultAsync(ct); // 3) cache if (user is not null && r is not null) { try { var payload = JsonSerializer.Serialize(user); var ttl = TimeSpan.FromMinutes(5); await r.StringSetAsync(IdentityEmailKey(tidStr, norm), payload, ttl); await r.StringSetAsync(UserIdKey(tidStr, user.Id), payload, ttl); } catch { /* ignore */ } } return user; } public async Task EmailExistsAsync(string email, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var norm = email.Trim().ToLowerInvariant(); return await db.UserIdentities.AsNoTracking() .AnyAsync(i => i.TenantId == tid && i.Type == IdentityType.Email && i.Identifier == norm, ct); } public async Task AddAsync(User user, CancellationToken ct = default) { var db = _scope.Get(); user.TenantId = TenantId(); await db.Users.AddAsync(user, ct); await db.SaveChangesAsync(ct); // cache var r = _redis?.GetDatabase(); if (r is not null) { try { var tid = user.TenantId.ToString(); var payload = JsonSerializer.Serialize(user); var ttl = TimeSpan.FromMinutes(5); await r.StringSetAsync(UserIdKey(tid, user.Id), payload, ttl); var email = user.Identities .FirstOrDefault(i => i.Type == IdentityType.Email && i.IsPrimary)?.Identifier; if (!string.IsNullOrWhiteSpace(email)) await r.StringSetAsync(IdentityEmailKey(tid, email!), payload, ttl); } catch { /* ignore */ } } } // ========= Identities ========= // (1) IMPLEMENTED: AddIdentityAsync public async Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var norm = identifier.Trim().ToLowerInvariant(); if (isPrimary) { var existingPrimary = await db.UserIdentities .Where(i => i.TenantId == tid && i.UserId == userId && i.Type == type && i.IsPrimary) .ToListAsync(ct); foreach (var x in existingPrimary) x.IsPrimary = false; } var entity = new UserIdentity { TenantId = tid, UserId = userId, Type = type, Identifier = norm, IsPrimary = isPrimary }; await db.UserIdentities.AddAsync(entity, ct); await db.SaveChangesAsync(ct); } // (2) IMPLEMENTED: VerifyIdentityAsync public async Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var norm = identifier.Trim().ToLowerInvariant(); var id = await db.UserIdentities .FirstOrDefaultAsync(i => i.TenantId == tid && i.UserId == userId && i.Type == type && i.Identifier == norm, ct); if (id is null) return; id.VerifiedAt = verifiedAt; await db.SaveChangesAsync(ct); } // (3) IMPLEMENTED: GetPrimaryIdentityAsync public async Task GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); return await db.UserIdentities.AsNoTracking() .FirstOrDefaultAsync(i => i.TenantId == tid && i.UserId == userId && i.Type == type && i.IsPrimary, ct); } // ========= Password ========= public async Task ChangePasswordAsync(Guid userId, string newPasswordHash, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct); if (user is null) return; user.PasswordHash = newPasswordHash; user.SecurityStamp = Guid.NewGuid().ToString("N"); await db.SaveChangesAsync(ct); } public async Task AddPasswordHistoryAsync(Guid userId, string passwordHash, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var h = new UserPasswordHistory { TenantId = tid, UserId = userId, PasswordHash = passwordHash, ChangedAt = DateTimeOffset.UtcNow }; await db.UserPasswordHistories.AddAsync(h, ct); await db.SaveChangesAsync(ct); } // ========= MFA ========= public async Task AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var f = new UserMfaFactor { TenantId = tid, UserId = userId, Type = MfaType.Totp, Label = label, Secret = secret, Enabled = true, AddedAt = DateTimeOffset.UtcNow }; await db.UserMfaFactors.AddAsync(f, ct); var u = await db.Users.FirstAsync(x => x.TenantId == tid && x.Id == userId, ct); u.MfaEnabled = true; await db.SaveChangesAsync(ct); return f; } public async Task DisableMfaFactorAsync(Guid factorId, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var f = await db.UserMfaFactors.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == factorId, ct); if (f is null) return; f.Enabled = false; var still = await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == f.UserId && x.Enabled, ct); if (!still) { var u = await db.Users.FirstAsync(x => x.TenantId == tid && x.Id == f.UserId, ct); u.MfaEnabled = false; } await db.SaveChangesAsync(ct); } public async Task HasAnyMfaAsync(Guid userId, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); return await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == userId && x.Enabled, ct); } // ========= Sessions ========= public async Task CreateSessionAsync(UserSession session, CancellationToken ct = default) { var db = _scope.Get(); session.TenantId = TenantId(); await db.UserSessions.AddAsync(session, ct); await db.SaveChangesAsync(ct); return session; } public async Task RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var s = await db.UserSessions.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == sessionId && x.UserId == userId, ct); if (s is null) return 0; s.RevokedAt = DateTimeOffset.UtcNow; return await db.SaveChangesAsync(ct); } public async Task RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default) { var db = _scope.Get(); var tid = TenantId(); var sessions = await db.UserSessions .Where(x => x.TenantId == tid && x.UserId == userId && x.RevokedAt == null) .ToListAsync(ct); foreach (var s in sessions) s.RevokedAt = DateTimeOffset.UtcNow; return await db.SaveChangesAsync(ct); } }