This commit is contained in:
Thanakarn Klangkasame
2025-09-30 11:01:02 +07:00
commit 92e614674c
182 changed files with 9596 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Storage;
using AMREZ.EOP.Domain.Entities.Tenancy;
using AMREZ.EOP.Infrastructures.Data;
using AMREZ.EOP.Infrastructures.Options;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace AMREZ.EOP.Infrastructures.Repositories;
public sealed class TenantRepository : ITenantRepository
{
private readonly IDbScope _scope;
public TenantRepository(IDbScope scope) => _scope = scope;
private AppDbContext Db() => _scope.Get<AppDbContext>();
private static string Norm(string s) => (s ?? string.Empty).Trim().ToLowerInvariant();
public Task<bool> TenantExistsAsync(string tenantKey, CancellationToken ct = default)
{
var key = Norm(tenantKey);
return Db().Set<TenantConfig>().AsNoTracking().AnyAsync(x => x.TenantKey == key, ct);
}
public Task<TenantConfig?> GetAsync(string tenantKey, CancellationToken ct = default)
{
var key = Norm(tenantKey);
return Db().Set<TenantConfig>().AsNoTracking().FirstOrDefaultAsync(x => x.TenantKey == key, ct);
}
public async Task<IReadOnlyList<TenantConfig>> ListTenantsAsync(CancellationToken ct = default)
=> await Db().Set<TenantConfig>().AsNoTracking().OrderBy(x => x.TenantKey).ToListAsync(ct);
public async Task<bool> CreateAsync(TenantConfig row, CancellationToken ct = default)
{
row.TenantKey = Norm(row.TenantKey);
row.Schema = string.IsNullOrWhiteSpace(row.Schema) ? null : row.Schema!.Trim();
row.ConnectionString = string.IsNullOrWhiteSpace(row.ConnectionString) ? null : row.ConnectionString!.Trim();
row.UpdatedAtUtc = DateTimeOffset.UtcNow;
await Db().Set<TenantConfig>().AddAsync(row, ct);
return await Db().SaveChangesAsync(ct) > 0;
}
public async Task<bool> UpdateAsync(TenantConfig row, DateTimeOffset? ifUnmodifiedSince, CancellationToken ct = default)
{
var key = Norm(row.TenantKey);
var db = Db();
var cur = await db.Set<TenantConfig>().FirstOrDefaultAsync(x => x.TenantKey == key, ct);
if (cur is null) return false;
if (ifUnmodifiedSince.HasValue && cur.UpdatedAtUtc > ifUnmodifiedSince.Value)
return false; // 412
cur.Schema = row.Schema is null ? cur.Schema : (string.IsNullOrWhiteSpace(row.Schema) ? null : row.Schema.Trim());
cur.ConnectionString = row.ConnectionString is null ? cur.ConnectionString : (string.IsNullOrWhiteSpace(row.ConnectionString) ? null : row.ConnectionString.Trim());
cur.Mode = row.Mode;
cur.IsActive = row.IsActive;
cur.UpdatedAtUtc = DateTimeOffset.UtcNow;
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<bool> DeleteAsync(string tenantKey, CancellationToken ct = default)
{
var key = Norm(tenantKey);
var db = Db();
var t = await db.Set<TenantConfig>().FirstOrDefaultAsync(x => x.TenantKey == key, ct);
if (t is null) return false;
// ❌ ไม่ลบนะ — ✅ deactivate
if (t.IsActive)
{
t.IsActive = false;
t.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
var domains = await db.Set<TenantDomain>()
.Where(d => d.TenantKey == key && d.IsActive)
.ToListAsync(ct);
foreach (var d in domains)
{
d.IsActive = false;
d.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<bool> MapDomainAsync(string domain, string tenantKey, CancellationToken ct = default)
{
var d = Norm(domain);
var key = Norm(tenantKey);
if (!await Db().Set<TenantConfig>().AnyAsync(x => x.TenantKey == key && x.IsActive, ct))
return false;
var db = Db();
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
if (ex is null)
await db.Set<TenantDomain>().AddAsync(new TenantDomain { Domain = d, TenantKey = key, IsPlatformBaseDomain = false, IsActive = true, UpdatedAtUtc = DateTimeOffset.UtcNow }, ct);
else
{
ex.TenantKey = key;
ex.IsPlatformBaseDomain = false;
ex.IsActive = true;
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<bool> UnmapDomainAsync(string domain, CancellationToken ct = default)
{
var d = Norm(domain);
var db = Db();
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
if (ex is null) return false;
ex.IsActive = false;
ex.TenantKey = null;
ex.IsPlatformBaseDomain = false;
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<IReadOnlyList<TenantDomain>> ListDomainsAsync(string? tenantKey, CancellationToken ct = default)
{
var db = Db();
var q = db.Set<TenantDomain>().AsNoTracking();
if (!string.IsNullOrWhiteSpace(tenantKey))
{
var key = Norm(tenantKey!);
q = q.Where(x => x.TenantKey == key || x.IsPlatformBaseDomain);
}
return await q.OrderBy(x => x.Domain).ToListAsync(ct);
}
public async Task<bool> AddBaseDomainAsync(string baseDomain, CancellationToken ct = default)
{
var d = Norm(baseDomain);
var db = Db();
var ex = await db.Set<TenantDomain>().FirstOrDefaultAsync(x => x.Domain == d, ct);
if (ex is null)
await db.Set<TenantDomain>().AddAsync(new TenantDomain { Domain = d, TenantKey = null, IsPlatformBaseDomain = true, IsActive = true, UpdatedAtUtc = DateTimeOffset.UtcNow }, ct);
else
{
ex.TenantKey = null;
ex.IsPlatformBaseDomain = true;
ex.IsActive = true;
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return await db.SaveChangesAsync(ct) > 0;
}
public async Task<bool> RemoveBaseDomainAsync(string baseDomain, CancellationToken ct = default)
{
var d = Norm(baseDomain);
var db = Db();
var ex = await db.Set<TenantDomain>()
.FirstOrDefaultAsync(x => x.Domain == d && x.IsPlatformBaseDomain, ct);
if (ex is null) return false;
ex.IsActive = false;
ex.UpdatedAtUtc = DateTimeOffset.UtcNow;
return await db.SaveChangesAsync(ct) > 0;
}
}

View File

@@ -0,0 +1,144 @@
using AMREZ.EOP.Abstractions.Applications.Tenancy;
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Storage;
using AMREZ.EOP.Domain.Entities.HumanResources;
using AMREZ.EOP.Infrastructures.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
namespace AMREZ.EOP.Infrastructures.Repositories;
public class UserProfileRepository : IUserProfileRepository
{
private readonly IDbScope _scope;
private readonly ITenantResolver _tenantResolver;
private readonly IHttpContextAccessor _http;
public UserProfileRepository(IDbScope scope, ITenantResolver tenantResolver, IHttpContextAccessor http)
{ _scope = scope; _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;
}
public async Task<UserProfile?> GetByUserIdAsync(Guid userId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
return await db.UserProfiles.FirstOrDefaultAsync(p => p.TenantId == tid && p.UserId == userId, ct);
}
public async Task UpsertAsync(UserProfile profile, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var existing = await db.UserProfiles.FirstOrDefaultAsync(p => p.TenantId == tid && p.UserId == profile.UserId, ct);
if (existing is null)
{
profile.TenantId = tid;
await db.UserProfiles.AddAsync(profile, ct);
}
else
{
existing.FirstName = profile.FirstName;
existing.LastName = profile.LastName;
existing.MiddleName = profile.MiddleName;
existing.Nickname = profile.Nickname;
existing.DateOfBirth = profile.DateOfBirth;
existing.Gender = profile.Gender;
}
await db.SaveChangesAsync(ct);
}
public async Task<Employment> AddEmploymentAsync(Employment e, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
e.TenantId = TenantId();
await db.Employments.AddAsync(e, ct);
await db.SaveChangesAsync(ct);
return e;
}
public async Task EndEmploymentAsync(Guid employmentId, DateTime endDate, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var e = await db.Employments.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == employmentId, ct);
if (e is null) return;
e.EndDate = endDate;
await db.SaveChangesAsync(ct);
}
public async Task<EmployeeAddress> AddAddressAsync(EmployeeAddress a, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
a.TenantId = TenantId();
await db.EmployeeAddresses.AddAsync(a, ct);
await db.SaveChangesAsync(ct);
return a;
}
public async Task SetPrimaryAddressAsync(Guid userProfileId, Guid addressId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var all = await db.EmployeeAddresses.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct);
foreach (var x in all) x.IsPrimary = false;
var target = all.FirstOrDefault(x => x.Id == addressId);
if (target is not null) target.IsPrimary = true;
await db.SaveChangesAsync(ct);
}
public async Task<EmergencyContact> AddEmergencyContactAsync(EmergencyContact c, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
c.TenantId = TenantId();
await db.EmergencyContacts.AddAsync(c, ct);
await db.SaveChangesAsync(ct);
return c;
}
public async Task SetPrimaryEmergencyContactAsync(Guid userProfileId, Guid contactId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var all = await db.EmergencyContacts.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct);
foreach (var x in all) x.IsPrimary = false;
var target = all.FirstOrDefault(x => x.Id == contactId);
if (target is not null) target.IsPrimary = true;
await db.SaveChangesAsync(ct);
}
public async Task<EmployeeBankAccount> AddBankAccountAsync(EmployeeBankAccount b, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
b.TenantId = TenantId();
await db.EmployeeBankAccounts.AddAsync(b, ct);
await db.SaveChangesAsync(ct);
return b;
}
public async Task SetPrimaryBankAccountAsync(Guid userProfileId, Guid bankAccountId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
var all = await db.EmployeeBankAccounts.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct);
foreach (var x in all) x.IsPrimary = false;
var target = all.FirstOrDefault(x => x.Id == bankAccountId);
if (target is not null) target.IsPrimary = true;
await db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,329 @@
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<User?> FindByIdAsync(Guid userId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
return await db.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct);
}
public async Task<User?> 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<User>(cached!);
if (hit?.IsActive == true) return hit;
}
}
catch { /* ignore → query DB */ }
}
// 2) EF: join identities
var db = _scope.Get<AppDbContext>();
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<bool> EmailExistsAsync(string email, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
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<AppDbContext>();
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<AppDbContext>();
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<AppDbContext>();
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<UserIdentity?> GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
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<AppDbContext>();
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<AppDbContext>();
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<UserMfaFactor> AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
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<AppDbContext>();
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<bool> HasAnyMfaAsync(Guid userId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
var tid = TenantId();
return await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == userId && x.Enabled, ct);
}
// ========= Sessions =========
public async Task<UserSession> CreateSessionAsync(UserSession session, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
session.TenantId = TenantId();
await db.UserSessions.AddAsync(session, ct);
await db.SaveChangesAsync(ct);
return session;
}
public async Task<int> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
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<int> RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default)
{
var db = _scope.Get<AppDbContext>();
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);
}
}