commit 92e614674cf775f99453b33d660dbd3347292712
Author: Thanakarn Klangkasame <77600906+Simulationable@users.noreply.github.com>
Date: Tue Sep 30 11:01:02 2025 +0700
Init Git
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..a9e2c35
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+.git
+**/bin
+**/obj
+**/TestResults
+**/*.user
+**/*.swp
+**/*.suo
+*.md
+*.ps1
+*.sh
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4ff7e6b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,62 @@
+
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# Build results
+[Dd]ebug/
+[Rr]elease/
+x64/
+x86/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio Code
+.vscode/
+
+# Rider
+.idea/
+*.sln.iml
+
+# NuGet
+*.nupkg
+*.snupkg
+.nuget/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# Dotnet watch
+**/node_modules/
+**/dist/
+**/tmp/
+**/.tmp/
+
+# Others
+*.dbmdl
+*.bak
+*.sql
+*.pdb
+*.cache
+*.config
+*.log
+*.vspscc
+*.vssscc
+*.vs/
+
+# Docker
+docker-compose.override.yml
diff --git a/AMREZ.EOP.API/AMREZ.EOP.API.csproj b/AMREZ.EOP.API/AMREZ.EOP.API.csproj
new file mode 100644
index 0000000..be74a5e
--- /dev/null
+++ b/AMREZ.EOP.API/AMREZ.EOP.API.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AMREZ.EOP.API/AMREZ.EOP.API.http b/AMREZ.EOP.API/AMREZ.EOP.API.http
new file mode 100644
index 0000000..000cc58
--- /dev/null
+++ b/AMREZ.EOP.API/AMREZ.EOP.API.http
@@ -0,0 +1,6 @@
+@AMREZ.EOP.API_HostAddress = http://localhost:5063
+
+GET {{AMREZ.EOP.API_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/AMREZ.EOP.API/Controllers/AuthenticationController.cs b/AMREZ.EOP.API/Controllers/AuthenticationController.cs
new file mode 100644
index 0000000..6ea7461
--- /dev/null
+++ b/AMREZ.EOP.API/Controllers/AuthenticationController.cs
@@ -0,0 +1,136 @@
+using System.Security.Claims;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity;
+using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword;
+using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa;
+using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
+using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
+using AMREZ.EOP.Contracts.DTOs.Authentications.Logout;
+using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll;
+using AMREZ.EOP.Contracts.DTOs.Authentications.Register;
+using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail;
+using AMREZ.EOP.Domain.Shared.Contracts;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AMREZ.EOP.API.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class AuthenticationController : ControllerBase
+{
+ private readonly ILoginUseCase _login;
+ private readonly IRegisterUseCase _register;
+ private readonly IChangePasswordUseCase _changePassword;
+
+ private readonly IAddEmailIdentityUseCase _addEmail;
+ private readonly IVerifyEmailUseCase _verifyEmail;
+
+ private readonly IEnableTotpUseCase _enableTotp;
+ private readonly IDisableMfaUseCase _disableMfa;
+
+ private readonly ILogoutUseCase _logout;
+ private readonly ILogoutAllUseCase _logoutAll;
+
+ public AuthenticationController(
+ ILoginUseCase login,
+ IRegisterUseCase register,
+ IChangePasswordUseCase changePassword)
+ {
+ _login = login;
+ _register = register;
+ _changePassword = changePassword;
+ }
+
+ [HttpPost("login")]
+ public async Task PostLogin([FromBody] LoginRequest body, CancellationToken ct)
+ {
+ var res = await _login.ExecuteAsync(body, ct);
+ if (res is null) return Unauthorized(new { message = "Invalid credentials" });
+
+ var claims = new List
+ {
+ new(ClaimTypes.NameIdentifier, res.UserId.ToString()),
+ new(ClaimTypes.Name, string.IsNullOrWhiteSpace(res.DisplayName) ? res.Email : res.DisplayName),
+ new(ClaimTypes.Email, res.Email),
+ new("tenant", res.TenantId)
+ };
+
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthPolicies.Scheme));
+ await HttpContext.SignInAsync(AuthPolicies.Scheme, principal);
+
+ return Ok(res);
+ }
+
+ [HttpPost("register")]
+ public async Task Register([FromBody] RegisterRequest body, CancellationToken ct)
+ {
+ var res = await _register.ExecuteAsync(body, ct);
+ if (res is null)
+ return Conflict(new { message = "Email already exists or tenant not found" });
+
+ return Created($"/api/authentication/users/{res.UserId}", res);
+ }
+
+ [HttpPost("change-password")]
+ public async Task ChangePassword([FromBody] ChangePasswordRequest body, CancellationToken ct)
+ {
+ var ok = await _changePassword.ExecuteAsync(body, ct);
+ if (!ok) return BadRequest(new { message = "Change password failed" });
+ return NoContent();
+ }
+
+ [HttpPost("logout")]
+ public async Task Logout()
+ {
+ await HttpContext.SignOutAsync(AuthPolicies.Scheme);
+ return NoContent();
+ }
+
+ [HttpPost("email")]
+ public async Task AddEmail([FromBody] AddEmailIdentityRequest body, CancellationToken ct)
+ {
+ var ok = await _addEmail.ExecuteAsync(body, ct);
+ if (!ok) return BadRequest(new { message = "Cannot add email identity" });
+ return NoContent();
+ }
+
+ [HttpPost("email/verify")]
+ public async Task VerifyEmail([FromBody] VerifyEmailRequest body, CancellationToken ct)
+ {
+ var ok = await _verifyEmail.ExecuteAsync(body, ct);
+ if (!ok) return BadRequest(new { message = "Verify email failed" });
+ return NoContent();
+ }
+
+ [HttpPost("totp/enable")]
+ public async Task EnableTotp([FromBody] EnableTotpRequest body, CancellationToken ct)
+ {
+ var res = await _enableTotp.ExecuteAsync(body, ct);
+ if (res is null) return BadRequest(new { message = "Enable TOTP failed" });
+ return Ok(res);
+ }
+
+ [HttpPost("disable")]
+ public async Task DisableMfa([FromBody] DisableMfaRequest body, CancellationToken ct)
+ {
+ var ok = await _disableMfa.ExecuteAsync(body, ct);
+ if (!ok) return BadRequest(new { message = "Disable MFA failed" });
+ return NoContent();
+ }
+
+ [HttpPost("revoke")]
+ public async Task Revoke([FromBody] LogoutRequest body, CancellationToken ct)
+ {
+ var ok = await _logout.ExecuteAsync(body, ct);
+ if (!ok) return NotFound(new { message = "Session not found" });
+ return NoContent();
+ }
+
+ [HttpPost("revoke-all")]
+ public async Task RevokeAll([FromBody] LogoutAllRequest body, CancellationToken ct)
+ {
+ var n = await _logoutAll.ExecuteAsync(body, ct);
+ return Ok(new { revoked = n });
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.API/Controllers/HumanResourcesController.cs b/AMREZ.EOP.API/Controllers/HumanResourcesController.cs
new file mode 100644
index 0000000..074e5d3
--- /dev/null
+++ b/AMREZ.EOP.API/Controllers/HumanResourcesController.cs
@@ -0,0 +1,128 @@
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AMREZ.EOP.API.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class HumanResourcesController : ControllerBase
+{
+ private readonly IUpsertUserProfileUseCase _upsertProfile;
+ private readonly IAddEmploymentUseCase _addEmployment;
+ private readonly IEndEmploymentUseCase _endEmployment;
+ private readonly IAddEmployeeAddressUseCase _addAddress;
+ private readonly ISetPrimaryAddressUseCase _setPrimaryAddress;
+ private readonly IAddEmergencyContactUseCase _addEmergencyContact;
+ private readonly ISetPrimaryEmergencyContactUseCase _setPrimaryEmergencyContact;
+ private readonly IAddEmployeeBankAccountUseCase _addBankAccount;
+ private readonly ISetPrimaryBankAccountUseCase _setPrimaryBankAccount;
+
+ public HumanResourcesController(
+ IUpsertUserProfileUseCase upsertProfile,
+ IAddEmploymentUseCase addEmployment,
+ IEndEmploymentUseCase endEmployment,
+ IAddEmployeeAddressUseCase addAddress,
+ ISetPrimaryAddressUseCase setPrimaryAddress,
+ IAddEmergencyContactUseCase addEmergencyContact,
+ ISetPrimaryEmergencyContactUseCase setPrimaryEmergencyContact,
+ IAddEmployeeBankAccountUseCase addBankAccount,
+ ISetPrimaryBankAccountUseCase setPrimaryBankAccount)
+ {
+ _upsertProfile = upsertProfile;
+ _addEmployment = addEmployment;
+ _endEmployment = endEmployment;
+ _addAddress = addAddress;
+ _setPrimaryAddress = setPrimaryAddress;
+ _addEmergencyContact = addEmergencyContact;
+ _setPrimaryEmergencyContact = setPrimaryEmergencyContact;
+ _addBankAccount = addBankAccount;
+ _setPrimaryBankAccount = setPrimaryBankAccount;
+ }
+
+ // ===== User Profile =====
+ [HttpPost("profiles/upsert")]
+ public async Task UpsertProfile([FromBody] UserProfileUpsertRequest body, CancellationToken ct)
+ {
+ var res = await _upsertProfile.ExecuteAsync(body, ct);
+ if (res is null) return BadRequest(new { message = "Upsert failed" });
+ return Ok(res);
+ }
+
+ // ===== Employment =====
+ [HttpPost("employments")]
+ public async Task AddEmployment([FromBody] EmploymentAddRequest body, CancellationToken ct)
+ {
+ var res = await _addEmployment.ExecuteAsync(body, ct);
+ if (res is null) return BadRequest(new { message = "Add employment failed" });
+ return Created($"/api/humanresources/employments/{res.Id}", res);
+ }
+
+ [HttpPost("employments/{id:guid}/end")]
+ public async Task EndEmployment([FromRoute] Guid id, [FromBody] EmploymentEndRequest body, CancellationToken ct)
+ {
+ if (id != body.EmploymentId) return BadRequest(new { message = "Mismatched id" });
+ var ok = await _endEmployment.ExecuteAsync(body, ct);
+ if (!ok) return NotFound(new { message = "Employment not found" });
+ return NoContent();
+ }
+
+ // ===== Addresses =====
+ [HttpPost("addresses")]
+ public async Task AddAddress([FromBody] EmployeeAddressAddRequest body, CancellationToken ct)
+ {
+ var res = await _addAddress.ExecuteAsync(body, ct);
+ if (res is null) return BadRequest(new { message = "Add address failed" });
+ return Created($"/api/humanresources/addresses/{res.Id}", res);
+ }
+
+ [HttpPost("addresses/set-primary")]
+ public async Task SetPrimaryAddress([FromBody] SetPrimaryAddressRequest body, CancellationToken ct)
+ {
+ var ok = await _setPrimaryAddress.ExecuteAsync(body, ct);
+ if (!ok) return NotFound(new { message = "Address not found" });
+ return NoContent();
+ }
+
+ // ===== Emergency Contacts =====
+ [HttpPost("emergency-contacts")]
+ public async Task AddEmergencyContact([FromBody] EmergencyContactAddRequest body, CancellationToken ct)
+ {
+ var res = await _addEmergencyContact.ExecuteAsync(body, ct);
+ if (res is null) return BadRequest(new { message = "Add emergency contact failed" });
+ return Created($"/api/humanresources/emergency-contacts/{res.Id}", res);
+ }
+
+ [HttpPost("emergency-contacts/set-primary")]
+ public async Task SetPrimaryEmergencyContact([FromBody] SetPrimaryEmergencyContactRequest body, CancellationToken ct)
+ {
+ var ok = await _setPrimaryEmergencyContact.ExecuteAsync(body, ct);
+ if (!ok) return NotFound(new { message = "Emergency contact not found" });
+ return NoContent();
+ }
+
+ // ===== Bank Accounts =====
+ [HttpPost("bank-accounts")]
+ public async Task AddBankAccount([FromBody] EmployeeBankAccountAddRequest body, CancellationToken ct)
+ {
+ var res = await _addBankAccount.ExecuteAsync(body, ct);
+ if (res is null) return BadRequest(new { message = "Add bank account failed" });
+ return Created($"/api/humanresources/bank-accounts/{res.Id}", res);
+ }
+
+ [HttpPost("bank-accounts/set-primary")]
+ public async Task SetPrimaryBankAccount([FromBody] SetPrimaryBankAccountRequest body, CancellationToken ct)
+ {
+ var ok = await _setPrimaryBankAccount.ExecuteAsync(body, ct);
+ if (!ok) return NotFound(new { message = "Bank account not found" });
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.API/Controllers/TenancyController.cs b/AMREZ.EOP.API/Controllers/TenancyController.cs
new file mode 100644
index 0000000..a7c09a3
--- /dev/null
+++ b/AMREZ.EOP.API/Controllers/TenancyController.cs
@@ -0,0 +1,192 @@
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AMREZ.EOP.API.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public sealed class TenancyController : ControllerBase
+{
+ private readonly ICreateTenantUseCase _createTenant;
+ private readonly IUpdateTenantUseCase _updateTenant;
+ private readonly IDeleteTenantUseCase _deleteTenant;
+ private readonly IMapDomainUseCase _mapDomain;
+ private readonly IUnmapDomainUseCase _unmapDomain;
+ private readonly IAddBaseDomainUseCase _addBaseDomain;
+ private readonly IRemoveBaseDomainUseCase _removeBaseDomain;
+ private readonly IListTenantsUseCase _listTenants;
+ private readonly IListDomainsUseCase _listDomains;
+ private readonly ITenantResolver _resolver;
+
+ public TenancyController(
+ ICreateTenantUseCase createTenant,
+ IUpdateTenantUseCase updateTenant,
+ IDeleteTenantUseCase deleteTenant,
+ IMapDomainUseCase mapDomain,
+ IUnmapDomainUseCase unmapDomain,
+ IAddBaseDomainUseCase addBaseDomain,
+ IRemoveBaseDomainUseCase removeBaseDomain,
+ IListTenantsUseCase listTenants,
+ IListDomainsUseCase listDomains,
+ ITenantResolver resolver)
+ {
+ _createTenant = createTenant;
+ _updateTenant = updateTenant;
+ _deleteTenant = deleteTenant;
+ _mapDomain = mapDomain;
+ _unmapDomain = unmapDomain;
+ _addBaseDomain = addBaseDomain;
+ _removeBaseDomain = removeBaseDomain;
+ _listTenants = listTenants;
+ _listDomains = listDomains;
+ _resolver = resolver;
+ }
+
+ private static object ErrTenantResolve => new
+ {
+ code = "TENANT_CONTEXT_NOT_RESOLVED",
+ message = "Check X-Tenant (e.g., public) or permission scope."
+ };
+
+ // Tenants
+ // Create: อย่า feed body เข้า resolver (tenant ยังไม่เกิด) ให้ใช้ context ปัจจุบันเท่านั้น
+ [HttpPost("tenants")]
+ public async Task CreateTenant([FromBody] CreateTenantRequest body, CancellationToken ct)
+ {
+ var resolved = _resolver.Resolve(HttpContext, hint: null);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var res = await _createTenant.ExecuteAsync(body, ct);
+ if (res is null)
+ return Conflict(new { code = "TENANT_EXISTS", message = "Use PUT to update/reactivate." });
+
+ return Created($"/api/Tenancy/tenants/{res.TenantKey}", res);
+ }
+
+ [HttpPut("tenants/{tenantKey}")]
+ public async Task UpdateTenant([FromRoute] string tenantKey, [FromBody] UpdateTenantRequest body, CancellationToken ct)
+ {
+ if (!string.Equals(tenantKey?.Trim(), body.TenantKey?.Trim(), StringComparison.OrdinalIgnoreCase))
+ return BadRequest(new { code = "TENANT_KEY_MISMATCH", message = "Route tenantKey must match body.TenantKey" });
+
+ // การแก้ไข tenant ที่มีอยู่: ให้ resolver ใช้ tenantKey เป็น hint เพื่อบังคับ context ให้ถูกต้อง
+ var resolved = _resolver.Resolve(HttpContext, hint: null);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var res = await _updateTenant.ExecuteAsync(body, ct);
+ if (res is null)
+ return NotFound(new { code = "TENANT_UPDATE_FAILED", message = "Update failed (not found, precondition, or tenant not resolved)" });
+
+ return Ok(res);
+ }
+
+ [HttpDelete("tenants/{tenantKey}")]
+ public async Task DeleteTenant([FromRoute] string tenantKey, CancellationToken ct)
+ {
+ var resolved = _resolver.Resolve(HttpContext, hint: null);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var ok = await _deleteTenant.ExecuteAsync(tenantKey, ct);
+ if (!ok)
+ return NotFound(new { code = "TENANT_DELETE_FAILED", message = "Delete failed (not found or tenant not resolved)" });
+
+ return NoContent();
+ }
+
+ [HttpGet("tenants")]
+ public async Task ListTenants(CancellationToken ct)
+ {
+ var resolved = _resolver.Resolve(HttpContext, hint: null);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var res = await _listTenants.ExecuteAsync(new ListTenantsRequest(), ct);
+ if (res is null)
+ return NotFound(new { code = "TENANT_LIST_FAILED", message = "Tenant resolve failed" });
+
+ return Ok(res);
+ }
+
+ // Domains
+ [HttpGet("domains")]
+ public async Task ListDomains([FromQuery] string? tenantKey, CancellationToken ct)
+ {
+ var resolved = _resolver.Resolve(HttpContext, hint: null);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var res = await _listDomains.ExecuteAsync(new ListDomainsRequest { TenantKey = tenantKey }, ct);
+ if (res is null)
+ return NotFound(new { code = "DOMAIN_LIST_FAILED", message = "Tenant resolve failed" });
+
+ return Ok(res);
+ }
+
+ [HttpPost("domains/map")]
+ public async Task MapDomain([FromBody] MapDomainRequest body, CancellationToken ct)
+ {
+ var resolved = _resolver.Resolve(HttpContext, hint: body?.TenantKey);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var res = await _mapDomain.ExecuteAsync(body, ct);
+ if (res is null)
+ return Conflict(new { code = "DOMAIN_MAP_FAILED", message = "Map failed (tenant/domain invalid or tenant not resolved)" });
+
+ return Ok(res);
+ }
+
+ [HttpDelete("domains/{domain}")]
+ public async Task UnmapDomain([FromRoute] string domain, [FromQuery] string? tenantKey, CancellationToken ct)
+ {
+ var resolved = _resolver.Resolve(HttpContext, hint: null);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var res = await _unmapDomain.ExecuteAsync(new UnmapDomainRequest { Domain = domain, TenantKey = tenantKey }, ct);
+ if (res is null)
+ return NotFound(new { code = "DOMAIN_UNMAP_FAILED", message = "Unmap failed (not found or tenant not resolved)" });
+
+ return NoContent();
+ }
+
+ [HttpPost("domains/base")]
+ public async Task AddBaseDomain([FromBody] AddBaseDomainRequest body, CancellationToken ct)
+ {
+ var resolved = _resolver.Resolve(HttpContext, hint: null);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var res = await _addBaseDomain.ExecuteAsync(body, ct);
+ if (res is null)
+ return Conflict(new { code = "BASE_DOMAIN_ADD_FAILED", message = "Add base domain failed (tenant not resolved or duplicate)" });
+
+ return Ok(res);
+ }
+
+ [HttpDelete("domains/base/{baseDomain}")]
+ public async Task RemoveBaseDomain([FromRoute] string baseDomain, [FromQuery] string? tenantKey, CancellationToken ct)
+ {
+ var resolved = _resolver.Resolve(HttpContext, hint: null);
+ if (resolved is null)
+ return NotFound(ErrTenantResolve);
+
+ var res = await _removeBaseDomain.ExecuteAsync(new RemoveBaseDomainRequest { BaseDomain = baseDomain, TenantKey = tenantKey }, ct);
+ if (res is null)
+ return NotFound(new { code = "BASE_DOMAIN_REMOVE_FAILED", message = "Remove base domain failed (not found or tenant not resolved)" });
+
+ return NoContent();
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.API/Filters/SkipTenantGuardAttribute.cs b/AMREZ.EOP.API/Filters/SkipTenantGuardAttribute.cs
new file mode 100644
index 0000000..fc3231f
--- /dev/null
+++ b/AMREZ.EOP.API/Filters/SkipTenantGuardAttribute.cs
@@ -0,0 +1,4 @@
+namespace AMREZ.EOP.API.Filters;
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
+public sealed class SkipTenantGuardAttribute : Attribute { }
\ No newline at end of file
diff --git a/AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs b/AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs
new file mode 100644
index 0000000..97a2a68
--- /dev/null
+++ b/AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs
@@ -0,0 +1,114 @@
+using System.Security.Claims;
+using System.Text.RegularExpressions;
+using AMREZ.EOP.API.Filters;
+using AMREZ.EOP.Infrastructures.Tenancy;
+
+namespace AMREZ.EOP.API.Middleware;
+
+public sealed class StrictTenantGuardOptions
+{
+ public string PlatformSlug { get; set; } = "public";
+ public string HeaderName { get; set; } = "X-Tenant";
+ public string ClaimName { get; set; } = "tenant"; // ปรับเป็น tid/org ได้ตามระบบ
+ public HashSet<(string method, string pathPrefix)> Allowlist { get; } = new()
+ {
+ ("GET", "/health"),
+ ("GET", "/swagger"),
+ ("GET", "/swagger/index.html"),
+ ("GET", "/swagger/"),
+ };
+ public Regex SlugRegex { get; set; } = new(@"^[a-z0-9\-]{1,64}$", RegexOptions.Compiled);
+}
+
+public sealed class StrictTenantGuardMiddleware : IMiddleware
+{
+ private readonly ILogger _log;
+ private readonly StrictTenantGuardOptions _opts;
+ private readonly TenantMap _map;
+
+ public StrictTenantGuardMiddleware(
+ ILogger log,
+ StrictTenantGuardOptions opts,
+ TenantMap map)
+ { _log = log; _opts = opts; _map = map; }
+
+ public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
+ {
+ // 1) allowlist แบบเร็ว
+ var path = ctx.Request.Path.Value ?? string.Empty;
+ if (_opts.Allowlist.Any(p =>
+ ctx.Request.Method.Equals(p.method, StringComparison.OrdinalIgnoreCase) &&
+ path.StartsWith(p.pathPrefix, StringComparison.OrdinalIgnoreCase)))
+ {
+ await next(ctx);
+ return;
+ }
+
+ var endpoint = ctx.GetEndpoint();
+ if (endpoint?.Metadata?.GetMetadata() is not null)
+ {
+ await next(ctx);
+ return;
+ }
+
+ // 3) ดึง header
+ var raw = ctx.Request.Headers[_opts.HeaderName].ToString()?.Trim();
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await ctx.Response.WriteAsJsonAsync(new { code = "MISSING_TENANT", message = $"{_opts.HeaderName} required" });
+ return;
+ }
+
+ var slug = raw.ToLowerInvariant();
+ if (!_opts.SlugRegex.IsMatch(slug))
+ {
+ ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
+ await ctx.Response.WriteAsJsonAsync(new { code = "INVALID_TENANT_FORMAT", message = "tenant slug invalid" });
+ return;
+ }
+
+ // 4) ตรวจ claim ให้ตรงกับ header
+ // var claim = GetTenantClaim(ctx.User, _opts.ClaimName);
+ // if (claim is null || !string.Equals(claim, slug, StringComparison.Ordinal))
+ // {
+ // ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
+ // await ctx.Response.WriteAsJsonAsync(new { code = "TENANT_MISMATCH", message = "token tenant != header" });
+ // return;
+ // }
+
+ // 5) ตรวจว่ามีอยู่จริงในระบบ (TenantMap/meta)
+ if (!_map.TryGetBySlug(slug, out var ctxTenant))
+ {
+ ctx.Response.StatusCode = StatusCodes.Status404NotFound;
+ await ctx.Response.WriteAsJsonAsync(new { code = "TENANT_NOT_FOUND", message = "unknown tenant" });
+ return;
+ }
+
+ // 6) สำหรับ /api/Tenancy/** → ใช้ platform DB แต่ยังต้องรู้ target tenant
+ var isControlPlane = path.StartsWith("/api/Tenancy", StringComparison.OrdinalIgnoreCase);
+ if (isControlPlane)
+ {
+ // เก็บ target tenant ให้ use case ใช้
+ ctx.Items["TargetTenantKey"] = slug;
+
+ // บังคับให้ resolver ใช้ platform (ภายหลัง)
+ ctx.Items["ForcePlatformContext"] = true;
+ }
+ else
+ {
+ // business-plane: ใช้ tenant context ปกติ (resolver จะอ่าน X-Tenant ตรง ๆ)
+ }
+
+ await next(ctx);
+ }
+
+ private static string? GetTenantClaim(ClaimsPrincipal user, string name)
+ {
+ if (user?.Identity?.IsAuthenticated != true) return null;
+ return user.FindFirstValue(name)
+ ?? user.FindFirstValue("tid")
+ ?? user.FindFirstValue("org")
+ ?? user.FindFirstValue("org_id");
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.API/Program.cs b/AMREZ.EOP.API/Program.cs
new file mode 100644
index 0000000..32e8c30
--- /dev/null
+++ b/AMREZ.EOP.API/Program.cs
@@ -0,0 +1,141 @@
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.API.Middleware;
+using AMREZ.EOP.Domain.Shared.Contracts;
+using AMREZ.EOP.Domain.Shared.Tenancy;
+using AMREZ.EOP.Infrastructures.Data;
+using AMREZ.EOP.Infrastructures.DependencyInjections;
+using AMREZ.EOP.Infrastructures.Options;
+using AMREZ.EOP.Infrastructures.Tenancy;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Models;
+using Npgsql;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.Configure(builder.Configuration.GetSection("Connections"));
+
+builder.Services.AddInfrastructure();
+
+builder.Services.AddOpenApi();
+
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Title = "AMREZ.EOP API",
+ Version = "v1"
+ });
+
+ // Cookie auth (ชื่อคุกกี้ eop.auth)
+ c.AddSecurityDefinition("cookieAuth", new OpenApiSecurityScheme
+ {
+ Type = SecuritySchemeType.ApiKey,
+ In = ParameterLocation.Cookie,
+ Name = "eop.auth",
+ Description = "Sign in via /api/authentication/login to receive cookie."
+ });
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme{
+ Reference = new OpenApiReference{ Type = ReferenceType.SecurityScheme, Id = "cookieAuth" }
+ },
+ Array.Empty()
+ }
+ });
+});
+
+builder.Services
+ .AddAuthentication(AuthPolicies.Scheme)
+ .AddCookie(AuthPolicies.Scheme, o => { o.LoginPath="/api/authentication/login"; o.Cookie.Name="eop.auth"; o.SlidingExpiration=true; });
+
+builder.Services.AddAuthorization();
+
+builder.Services.AddSingleton(new StrictTenantGuardOptions
+{
+ PlatformSlug = "public",
+ HeaderName = "X-Tenant",
+ ClaimName = "tenant"
+});
+builder.Services.AddTransient();
+
+builder.Services.AddControllers();
+
+var app = builder.Build();
+
+if (app.Environment.IsDevelopment())
+{
+ await ApplyMigrationsAsync(app.Services);
+
+ app.MapOpenApi();
+
+ app.UseSwagger();
+ app.UseSwaggerUI(o =>
+ {
+ o.SwaggerEndpoint("/swagger/v1/swagger.json", "AMREZ.EOP API v1");
+ o.RoutePrefix = "swagger";
+ o.DisplayRequestDuration();
+ });
+}
+
+app.UseHttpsRedirection();
+
+app.UseAuthentication();
+
+app.UseMiddleware();
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();
+
+static async Task ApplyMigrationsAsync(IServiceProvider services)
+{
+ using var scope = services.CreateScope();
+ var sp = scope.ServiceProvider;
+
+ var opts = sp.GetRequiredService>().Value;
+ var factory = sp.GetRequiredService();
+
+ if (!opts.UseSchemaPerTenant)
+ {
+ await using var db = factory.Create(new TenantContext { Id = "_shared_" });
+ await db.Database.MigrateAsync();
+ }
+
+ var schemas = await GetAppSchemasAsync(opts.DefaultConnection);
+
+ foreach (var schema in schemas)
+ {
+ var t = new TenantContext { Id = schema, Schema = schema, Mode = TenantMode.Schema };
+ await using var db = factory.Create(t);
+ await db.Database.MigrateAsync();
+ }
+}
+
+static async Task> GetAppSchemasAsync(string connectionString)
+{
+ var list = new List();
+
+ await using var conn = new NpgsqlConnection(connectionString);
+ await conn.OpenAsync();
+
+ const string sql = @"
+ SELECT nspname
+ FROM pg_namespace
+ WHERE nspname NOT LIKE 'pg_%'
+ AND nspname <> 'information_schema'
+ ORDER BY nspname;";
+
+ await using var cmd = new NpgsqlCommand(sql, conn);
+ await using var rd = await cmd.ExecuteReaderAsync();
+ while (await rd.ReadAsync())
+ list.Add(rd.GetString(0));
+
+ if (list.Count == 0) list.Add("public");
+
+ return list;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.API/Properties/launchSettings.json b/AMREZ.EOP.API/Properties/launchSettings.json
new file mode 100644
index 0000000..95557ab
--- /dev/null
+++ b/AMREZ.EOP.API/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5063",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7275;http://localhost:5063",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/AMREZ.EOP.API/appsettings.Development.json b/AMREZ.EOP.API/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/AMREZ.EOP.API/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/AMREZ.EOP.API/appsettings.json b/AMREZ.EOP.API/appsettings.json
new file mode 100644
index 0000000..ba290c6
--- /dev/null
+++ b/AMREZ.EOP.API/appsettings.json
@@ -0,0 +1,16 @@
+{
+ "Connections": {
+ "DefaultConnection": "Host=127.0.0.1;Port=15432;User ID=amrez;Password=Initial1!;Database=amrez-app-db;Search Path=public;",
+ "UseSchemaPerTenant": false,
+ "StorageBackend": "ef",
+ "RedisConnection": "localhost:6379",
+ "RedisDb": 0
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj b/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj
new file mode 100644
index 0000000..f1b38eb
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantContext.cs b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantContext.cs
new file mode 100644
index 0000000..53e33cf
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantContext.cs
@@ -0,0 +1,9 @@
+namespace AMREZ.EOP.Abstractions.Applications.Tenancy;
+
+public interface ITenantContext
+{
+ string TenantKey { get; }
+ string Id { get; }
+ string? Schema { get; }
+ string? ConnectionString { get; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantDbContextFactory.cs b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantDbContextFactory.cs
new file mode 100644
index 0000000..0fd0ed7
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantDbContextFactory.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Abstractions.Applications.Tenancy;
+
+public interface ITenantDbContextFactory
+{
+ TContext Create(ITenantContext tenant) where TContext : class;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantResolver.cs b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantResolver.cs
new file mode 100644
index 0000000..4ba964d
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantResolver.cs
@@ -0,0 +1,8 @@
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Abstractions.Applications.Tenancy;
+
+public interface ITenantResolver
+{
+ ITenantContext? Resolve(HttpContext http, object? hint = null);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IAddEmailIdentityUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IAddEmailIdentityUseCase.cs
new file mode 100644
index 0000000..a3f2d2f
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IAddEmailIdentityUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface IAddEmailIdentityUseCase
+{
+ Task ExecuteAsync(AddEmailIdentityRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IChangePasswordUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IChangePasswordUseCase.cs
new file mode 100644
index 0000000..2e5a572
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IChangePasswordUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface IChangePasswordUseCase
+{
+ Task ExecuteAsync(ChangePasswordRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IDisableMfaUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IDisableMfaUseCase.cs
new file mode 100644
index 0000000..ff9a3c8
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IDisableMfaUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface IDisableMfaUseCase
+{
+ Task ExecuteAsync(DisableMfaRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IEnableTotpUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IEnableTotpUseCase.cs
new file mode 100644
index 0000000..1886b3a
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IEnableTotpUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface IEnableTotpUseCase
+{
+ Task ExecuteAsync(EnableTotpRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILoginUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILoginUseCase.cs
new file mode 100644
index 0000000..cc188ce
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILoginUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface ILoginUseCase
+{
+ Task ExecuteAsync(LoginRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutAllUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutAllUseCase.cs
new file mode 100644
index 0000000..15c79c0
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutAllUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface ILogoutAllUseCase
+{
+ Task ExecuteAsync(LogoutAllRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutUseCase.cs
new file mode 100644
index 0000000..3f29155
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.Logout;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface ILogoutUseCase
+{
+ Task ExecuteAsync(LogoutRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IRegisterUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IRegisterUseCase.cs
new file mode 100644
index 0000000..ffc6982
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IRegisterUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.Register;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface IRegisterUseCase
+{
+ Task ExecuteAsync(RegisterRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IVerifyEmailUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IVerifyEmailUseCase.cs
new file mode 100644
index 0000000..ff5c9f0
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IVerifyEmailUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+
+public interface IVerifyEmailUseCase
+{
+ Task ExecuteAsync(VerifyEmailRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmergencyContactUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmergencyContactUseCase.cs
new file mode 100644
index 0000000..063fbba
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmergencyContactUseCase.cs
@@ -0,0 +1,9 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContact;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface IAddEmergencyContactUseCase
+{
+ Task ExecuteAsync(EmergencyContactAddRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeAddressUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeAddressUseCase.cs
new file mode 100644
index 0000000..c498ae5
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeAddressUseCase.cs
@@ -0,0 +1,9 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddress;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface IAddEmployeeAddressUseCase
+{
+ Task ExecuteAsync(EmployeeAddressAddRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeBankAccountUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeBankAccountUseCase.cs
new file mode 100644
index 0000000..af57a85
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeBankAccountUseCase.cs
@@ -0,0 +1,9 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccount;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface IAddEmployeeBankAccountUseCase
+{
+ Task ExecuteAsync(EmployeeBankAccountAddRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmploymentUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmploymentUseCase.cs
new file mode 100644
index 0000000..246f1f9
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmploymentUseCase.cs
@@ -0,0 +1,9 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.Employment;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface IAddEmploymentUseCase
+{
+ Task ExecuteAsync(EmploymentAddRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IEndEmploymentUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IEndEmploymentUseCase.cs
new file mode 100644
index 0000000..a51a124
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IEndEmploymentUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface IEndEmploymentUseCase
+{
+ Task ExecuteAsync(EmploymentEndRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryAddressUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryAddressUseCase.cs
new file mode 100644
index 0000000..35a3dfe
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryAddressUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface ISetPrimaryAddressUseCase
+{
+ Task ExecuteAsync(SetPrimaryAddressRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryBankAccountUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryBankAccountUseCase.cs
new file mode 100644
index 0000000..a5d14e7
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryBankAccountUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface ISetPrimaryBankAccountUseCase
+{
+ Task ExecuteAsync(SetPrimaryBankAccountRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryEmergencyContactUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryEmergencyContactUseCase.cs
new file mode 100644
index 0000000..165891c
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryEmergencyContactUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface ISetPrimaryEmergencyContactUseCase
+{
+ Task ExecuteAsync(SetPrimaryEmergencyContactRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IUpsertUserProfileUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IUpsertUserProfileUseCase.cs
new file mode 100644
index 0000000..5f48b48
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IUpsertUserProfileUseCase.cs
@@ -0,0 +1,9 @@
+using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfile;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+
+public interface IUpsertUserProfileUseCase
+{
+ Task ExecuteAsync(UserProfileUpsertRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IAddBaseDomainUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IAddBaseDomainUseCase.cs
new file mode 100644
index 0000000..fb50427
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IAddBaseDomainUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface IAddBaseDomainUseCase
+{
+ Task ExecuteAsync(AddBaseDomainRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ICreateTenantUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ICreateTenantUseCase.cs
new file mode 100644
index 0000000..eedda52
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ICreateTenantUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface ICreateTenantUseCase
+{
+ Task ExecuteAsync(CreateTenantRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IDeleteTenantUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IDeleteTenantUseCase.cs
new file mode 100644
index 0000000..8470c8e
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IDeleteTenantUseCase.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface IDeleteTenantUseCase
+{
+ Task ExecuteAsync(string tenantKey, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListDomainsUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListDomainsUseCase.cs
new file mode 100644
index 0000000..9524a1d
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListDomainsUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface IListDomainsUseCase
+{
+ Task ExecuteAsync(ListDomainsRequest request, CancellationToken ct = default);
+}
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListTenantsUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListTenantsUseCase.cs
new file mode 100644
index 0000000..2876396
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListTenantsUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface IListTenantsUseCase
+{
+ Task ExecuteAsync(ListTenantsRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IMapDomainUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IMapDomainUseCase.cs
new file mode 100644
index 0000000..0c8fb82
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IMapDomainUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface IMapDomainUseCase
+{
+ Task ExecuteAsync(MapDomainRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IRemoveBaseDomainUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IRemoveBaseDomainUseCase.cs
new file mode 100644
index 0000000..68d4498
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IRemoveBaseDomainUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface IRemoveBaseDomainUseCase
+{
+ Task ExecuteAsync(RemoveBaseDomainRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ITenantProvisioner.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ITenantProvisioner.cs
new file mode 100644
index 0000000..631f6c2
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ITenantProvisioner.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface ITenantProvisioner
+{
+ Task ProvisionAsync(string tenantKey, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUnmapDomainUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUnmapDomainUseCase.cs
new file mode 100644
index 0000000..9a88c27
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUnmapDomainUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface IUnmapDomainUseCase
+{
+ Task ExecuteAsync(UnmapDomainRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUpdateTenantUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUpdateTenantUseCase.cs
new file mode 100644
index 0000000..f0e5e8a
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUpdateTenantUseCase.cs
@@ -0,0 +1,8 @@
+using AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant;
+
+namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+
+public interface IUpdateTenantUseCase
+{
+ Task ExecuteAsync(UpdateTenantRequest request, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Common/IUnitOfWork.cs b/AMREZ.EOP.Abstractions/Infrastructures/Common/IUnitOfWork.cs
new file mode 100644
index 0000000..6e54904
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Infrastructures/Common/IUnitOfWork.cs
@@ -0,0 +1,12 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+
+namespace AMREZ.EOP.Abstractions.Infrastructures.Common;
+
+public interface IUnitOfWork
+{
+ Task BeginAsync(ITenantContext tenant, IsolationLevel isolation = IsolationLevel.ReadCommitted, CancellationToken ct = default);
+ Task CommitAsync(CancellationToken ct = default);
+ Task RollbackAsync(CancellationToken ct = default);
+ string Backend { get; } // "ef" | "redis"
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Repositories/ITenantRepository.cs b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/ITenantRepository.cs
new file mode 100644
index 0000000..59d2462
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/ITenantRepository.cs
@@ -0,0 +1,21 @@
+using AMREZ.EOP.Domain.Entities.Tenancy;
+
+namespace AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+
+public interface ITenantRepository
+{
+ Task TenantExistsAsync(string tenantKey, CancellationToken ct = default);
+ Task GetAsync(string tenantKey, CancellationToken ct = default);
+ Task> ListTenantsAsync(CancellationToken ct = default);
+
+ Task CreateAsync(TenantConfig row, CancellationToken ct = default);
+ Task UpdateAsync(TenantConfig row, DateTimeOffset? ifUnmodifiedSince, CancellationToken ct = default);
+ Task DeleteAsync(string tenantKey, CancellationToken ct = default);
+
+ Task MapDomainAsync(string domain, string tenantKey, CancellationToken ct = default);
+ Task UnmapDomainAsync(string domain, CancellationToken ct = default);
+ Task> ListDomainsAsync(string? tenantKey, CancellationToken ct = default);
+
+ Task AddBaseDomainAsync(string baseDomain, CancellationToken ct = default);
+ Task RemoveBaseDomainAsync(string baseDomain, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserProfileRepository.cs b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserProfileRepository.cs
new file mode 100644
index 0000000..1742d7c
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserProfileRepository.cs
@@ -0,0 +1,21 @@
+using AMREZ.EOP.Domain.Entities.HumanResources;
+
+namespace AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+
+public interface IUserProfileRepository
+{
+ Task GetByUserIdAsync(Guid userId, CancellationToken ct = default);
+ Task UpsertAsync(UserProfile profile, CancellationToken ct = default);
+
+ Task AddEmploymentAsync(Employment e, CancellationToken ct = default);
+ Task EndEmploymentAsync(Guid employmentId, DateTime endDate, CancellationToken ct = default);
+
+ Task AddAddressAsync(EmployeeAddress a, CancellationToken ct = default);
+ Task SetPrimaryAddressAsync(Guid userProfileId, Guid addressId, CancellationToken ct = default);
+
+ Task AddEmergencyContactAsync(EmergencyContact c, CancellationToken ct = default);
+ Task SetPrimaryEmergencyContactAsync(Guid userProfileId, Guid contactId, CancellationToken ct = default);
+
+ Task AddBankAccountAsync(EmployeeBankAccount b, CancellationToken ct = default);
+ Task SetPrimaryBankAccountAsync(Guid userProfileId, Guid bankAccountId, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserRepository.cs b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserRepository.cs
new file mode 100644
index 0000000..36b3836
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserRepository.cs
@@ -0,0 +1,31 @@
+using AMREZ.EOP.Domain.Entities.Authentications;
+using AMREZ.EOP.Domain.Shared._Users;
+
+namespace AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+
+public interface IUserRepository
+{
+ Task FindByIdAsync(Guid userId, CancellationToken ct = default);
+ Task FindActiveByEmailAsync(string email, CancellationToken ct = default);
+ Task EmailExistsAsync(string email, CancellationToken ct = default);
+ Task AddAsync(User user, CancellationToken ct = default);
+
+ // Identities
+ Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default);
+ Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default);
+ Task GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default);
+
+ // Password
+ Task ChangePasswordAsync(Guid userId, string newPasswordHash, CancellationToken ct = default);
+ Task AddPasswordHistoryAsync(Guid userId, string passwordHash, CancellationToken ct = default);
+
+ // MFA
+ Task AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default);
+ Task DisableMfaFactorAsync(Guid factorId, CancellationToken ct = default);
+ Task HasAnyMfaAsync(Guid userId, CancellationToken ct = default);
+
+ // Sessions
+ Task CreateSessionAsync(UserSession session, CancellationToken ct = default);
+ Task RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default);
+ Task RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Security/IPasswordHasher.cs b/AMREZ.EOP.Abstractions/Security/IPasswordHasher.cs
new file mode 100644
index 0000000..abad85f
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Security/IPasswordHasher.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Abstractions.Security;
+
+public interface IPasswordHasher
+{
+ bool Verify(string plain, string hash);
+ string Hash(string plain);
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Abstractions/Storage/IDbScope.cs b/AMREZ.EOP.Abstractions/Storage/IDbScope.cs
new file mode 100644
index 0000000..5193223
--- /dev/null
+++ b/AMREZ.EOP.Abstractions/Storage/IDbScope.cs
@@ -0,0 +1,9 @@
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+
+namespace AMREZ.EOP.Abstractions.Storage;
+
+public interface IDbScope
+{
+ void EnsureForTenant(ITenantContext tenant);
+ TContext Get() where TContext : class;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj b/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj
new file mode 100644
index 0000000..b1fd7ad
--- /dev/null
+++ b/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/AddEmailIdentityUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/AddEmailIdentityUseCase.cs
new file mode 100644
index 0000000..ec66d32
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/AddEmailIdentityUseCase.cs
@@ -0,0 +1,47 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity;
+using AMREZ.EOP.Domain.Shared._Users;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class AddEmailIdentityUseCase : IAddEmailIdentityUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserRepository _users;
+ private readonly IHttpContextAccessor _http;
+
+ public AddEmailIdentityUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _users = users;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(AddEmailIdentityRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var email = request.Email.Trim().ToLowerInvariant();
+ await _users.AddIdentityAsync(request.UserId, IdentityType.Email, email, request.IsPrimary, ct);
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/ChangePasswordUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/ChangePasswordUseCase.cs
new file mode 100644
index 0000000..b3a499c
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/ChangePasswordUseCase.cs
@@ -0,0 +1,47 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Abstractions.Security;
+using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class ChangePasswordUseCase : IChangePasswordUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserRepository _users;
+ private readonly IPasswordHasher _hasher;
+ private readonly IHttpContextAccessor _http;
+
+ public ChangePasswordUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IPasswordHasher h, IHttpContextAccessor http)
+ { _resolver = r; _uow = uow; _users = users; _hasher = h; _http = http; }
+
+ public async Task ExecuteAsync(ChangePasswordRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var user = await _users.FindByIdAsync(request.UserId, ct);
+ if (user is null) { await _uow.RollbackAsync(ct); return false; }
+
+ if (!_hasher.Verify(request.OldPassword, user.PasswordHash))
+ { await _uow.RollbackAsync(ct); return false; }
+
+ var newHash = _hasher.Hash(request.NewPassword);
+ await _users.AddPasswordHistoryAsync(user.Id, user.PasswordHash, ct);
+ await _users.ChangePasswordAsync(user.Id, newHash, ct);
+
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch { await _uow.RollbackAsync(ct); throw; }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/DisableMfaUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/DisableMfaUseCase.cs
new file mode 100644
index 0000000..067d06d
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/DisableMfaUseCase.cs
@@ -0,0 +1,36 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class DisableMfaUseCase : IDisableMfaUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserRepository _users;
+ private readonly IHttpContextAccessor _http;
+
+ public DisableMfaUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http)
+ { _resolver = r; _uow = uow; _users = users; _http = http; }
+
+ public async Task ExecuteAsync(DisableMfaRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ await _users.DisableMfaFactorAsync(request.FactorId, ct);
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch { await _uow.RollbackAsync(ct); throw; }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/EnableTotpUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/EnableTotpUseCase.cs
new file mode 100644
index 0000000..539a3ed
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/EnableTotpUseCase.cs
@@ -0,0 +1,36 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class EnableTotpUseCase : IEnableTotpUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserRepository _users;
+ private readonly IHttpContextAccessor _http;
+
+ public EnableTotpUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http)
+ { _resolver = r; _uow = uow; _users = users; _http = http; }
+
+ public async Task ExecuteAsync(EnableTotpRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var factor = await _users.AddTotpFactorAsync(request.UserId, request.Label, request.Secret, ct);
+ await _uow.CommitAsync(ct);
+ return new EnableTotpResponse(factor.Id, request.Label);
+ }
+ catch { await _uow.RollbackAsync(ct); throw; }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/LoginUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/LoginUseCase.cs
new file mode 100644
index 0000000..dfa2df1
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/LoginUseCase.cs
@@ -0,0 +1,51 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Abstractions.Security;
+using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class LoginUseCase : ILoginUseCase
+{
+ private readonly ITenantResolver _tenantResolver;
+ private readonly IUserRepository _users;
+ private readonly IPasswordHasher _hasher;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+
+ public LoginUseCase(ITenantResolver r, IUserRepository u, IPasswordHasher h, IHttpContextAccessor http, IUnitOfWork uow)
+ { _tenantResolver = r; _users = u; _hasher = h; _http = http; _uow = uow; }
+
+ public async Task ExecuteAsync(LoginRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _tenantResolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var email = request.Email.Trim().ToLowerInvariant();
+ var user = await _users.FindActiveByEmailAsync(email, ct);
+ if (user is null || !_hasher.Verify(request.Password, user.PasswordHash))
+ {
+ await _uow.RollbackAsync(ct);
+ return null;
+ }
+
+ await _uow.CommitAsync(ct);
+
+ // NOTE: ไม่ใช้ DisplayName ใน Entity แล้ว — ส่งกลับเป็นค่าว่าง/ไปดึงจาก HR ฝั่ง API
+ return new LoginResponse(user.Id, string.Empty, email, tenant.Id);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/LogoutAllUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/LogoutAllUseCase.cs
new file mode 100644
index 0000000..3d9d255
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/LogoutAllUseCase.cs
@@ -0,0 +1,45 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class LogoutAllUseCase : ILogoutAllUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserRepository _users;
+ private readonly IHttpContextAccessor _http;
+
+ public LogoutAllUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _users = users;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(LogoutAllRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return 0;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var n = await _users.RevokeAllSessionsAsync(request.UserId, ct);
+ await _uow.CommitAsync(ct);
+ return n;
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/LogoutUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/LogoutUseCase.cs
new file mode 100644
index 0000000..da25aa8
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/LogoutUseCase.cs
@@ -0,0 +1,36 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Authentications.Logout;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class LogoutUseCase : ILogoutUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserRepository _users;
+ private readonly IHttpContextAccessor _http;
+
+ public LogoutUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http)
+ { _resolver = r; _uow = uow; _users = users; _http = http; }
+
+ public async Task ExecuteAsync(LogoutRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var n = await _users.RevokeSessionAsync(request.UserId, request.SessionId, ct);
+ await _uow.CommitAsync(ct);
+ return n > 0;
+ }
+ catch { await _uow.RollbackAsync(ct); throw; }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/RegisterUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/RegisterUseCase.cs
new file mode 100644
index 0000000..034f6c6
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/RegisterUseCase.cs
@@ -0,0 +1,78 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Abstractions.Security;
+using AMREZ.EOP.Contracts.DTOs.Authentications.Register;
+using AMREZ.EOP.Domain.Entities.Authentications;
+using AMREZ.EOP.Domain.Shared._Users;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class RegisterUseCase : IRegisterUseCase
+{
+ private readonly ITenantResolver _tenantResolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserRepository _users;
+ private readonly IPasswordHasher _hasher;
+ private readonly IHttpContextAccessor _http;
+
+ public RegisterUseCase(
+ ITenantResolver resolver,
+ IUnitOfWork uow,
+ IUserRepository users,
+ IPasswordHasher hasher,
+ IHttpContextAccessor http)
+ {
+ _tenantResolver = resolver; _uow = uow; _users = users; _hasher = hasher; _http = http;
+ }
+
+ public async Task ExecuteAsync(RegisterRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _tenantResolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ var emailNorm = request.Email.Trim().ToLowerInvariant();
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ if (await _users.EmailExistsAsync(emailNorm, ct))
+ {
+ await _uow.RollbackAsync(ct);
+ return null;
+ }
+
+ var hash = _hasher.Hash(request.Password);
+
+ var user = new User
+ {
+ PasswordHash = hash,
+ IsActive = true,
+ };
+
+ // แนบอัตลักษณ์แบบ Email (เก็บในตารางลูก)
+ user.Identities.Add(new UserIdentity
+ {
+ Type = IdentityType.Email,
+ Identifier = emailNorm,
+ IsPrimary = true,
+ VerifiedAt = null
+ });
+
+ await _users.AddAsync(user, ct);
+ await _uow.CommitAsync(ct);
+
+ // ไม่ส่ง DisplayName (ปล่อยให้ HR/Presentation สร้าง)
+ return new RegisterResponse(user.Id, string.Empty, emailNorm, tenant.Id);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Authentications/VerifyEmailUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/VerifyEmailUseCase.cs
new file mode 100644
index 0000000..e4f0b7e
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Authentications/VerifyEmailUseCase.cs
@@ -0,0 +1,37 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail;
+using AMREZ.EOP.Domain.Shared._Users;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Authentications;
+
+public sealed class VerifyEmailUseCase : IVerifyEmailUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserRepository _users;
+ private readonly IHttpContextAccessor _http;
+
+ public VerifyEmailUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http)
+ { _resolver = r; _uow = uow; _users = users; _http = http; }
+
+ public async Task ExecuteAsync(VerifyEmailRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ await _users.VerifyIdentityAsync(request.UserId, IdentityType.Email, request.Email.Trim().ToLowerInvariant(), DateTimeOffset.UtcNow, ct);
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch { await _uow.RollbackAsync(ct); throw; }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/AddEmergencyContactUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmergencyContactUseCase.cs
new file mode 100644
index 0000000..65be24e
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmergencyContactUseCase.cs
@@ -0,0 +1,51 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContact;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd;
+using AMREZ.EOP.Domain.Entities.HumanResources;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class AddEmergencyContactUseCase : IAddEmergencyContactUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public AddEmergencyContactUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, IHttpContextAccessor http)
+ { _resolver = r; _uow = uow; _hr = hr; _http = http; }
+
+ public async Task ExecuteAsync(EmergencyContactAddRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var ec = new EmergencyContact
+ {
+ UserProfileId = request.UserProfileId,
+ Name = request.Name,
+ Relationship = request.Relationship,
+ Phone = request.Phone,
+ Email = request.Email,
+ IsPrimary = request.IsPrimary
+ };
+
+ var added = await _hr.AddEmergencyContactAsync(ec, ct);
+ if (request.IsPrimary)
+ await _hr.SetPrimaryEmergencyContactAsync(request.UserProfileId, added.Id, ct);
+
+ await _uow.CommitAsync(ct);
+ return new EmergencyContactResponse(added.Id, added.UserProfileId, added.IsPrimary);
+ }
+ catch { await _uow.RollbackAsync(ct); throw; }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeAddressUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeAddressUseCase.cs
new file mode 100644
index 0000000..5361637
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeAddressUseCase.cs
@@ -0,0 +1,65 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddress;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd;
+using AMREZ.EOP.Domain.Entities.HumanResources;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class AddEmployeeAddressUseCase : IAddEmployeeAddressUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public AddEmployeeAddressUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr,
+ IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _hr = hr;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(EmployeeAddressAddRequest request,
+ CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var addr = new EmployeeAddress
+ {
+ UserProfileId = request.UserProfileId,
+ Type = request.Type,
+ Line1 = request.Line1,
+ Line2 = request.Line2,
+ City = request.City,
+ State = request.State,
+ PostalCode = request.PostalCode,
+ Country = request.Country,
+ IsPrimary = request.IsPrimary
+ };
+
+ var added = await _hr.AddAddressAsync(addr, ct);
+ if (request.IsPrimary)
+ await _hr.SetPrimaryAddressAsync(request.UserProfileId, added.Id, ct);
+
+ await _uow.CommitAsync(ct);
+ return new EmployeeAddressResponse(added.Id, added.UserProfileId, added.IsPrimary);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeBankAccountUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeBankAccountUseCase.cs
new file mode 100644
index 0000000..6e4f425
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeBankAccountUseCase.cs
@@ -0,0 +1,63 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccount;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd;
+using AMREZ.EOP.Domain.Entities.HumanResources;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class AddEmployeeBankAccountUseCase : IAddEmployeeBankAccountUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public AddEmployeeBankAccountUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr,
+ IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _hr = hr;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(EmployeeBankAccountAddRequest request,
+ CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var b = new EmployeeBankAccount
+ {
+ UserProfileId = request.UserProfileId,
+ BankName = request.BankName,
+ AccountNumber = request.AccountNumber,
+ AccountHolder = request.AccountHolder,
+ Branch = request.Branch,
+ Note = request.Note,
+ IsPrimary = request.IsPrimary
+ };
+
+ var added = await _hr.AddBankAccountAsync(b, ct);
+ if (request.IsPrimary)
+ await _hr.SetPrimaryBankAccountAsync(request.UserProfileId, added.Id, ct);
+
+ await _uow.CommitAsync(ct);
+ return new EmployeeBankAccountResponse(added.Id, added.UserProfileId, added.IsPrimary);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/AddEmploymentUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmploymentUseCase.cs
new file mode 100644
index 0000000..76fa3c9
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmploymentUseCase.cs
@@ -0,0 +1,60 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.Employment;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd;
+using AMREZ.EOP.Domain.Entities.HumanResources;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class AddEmploymentUseCase : IAddEmploymentUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public AddEmploymentUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr,
+ IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _hr = hr;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(EmploymentAddRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var e = new Employment
+ {
+ UserProfileId = request.UserProfileId,
+ EmploymentType = request.EmploymentType,
+ StartDate = request.StartDate,
+ DepartmentId = request.DepartmentId,
+ PositionId = request.PositionId,
+ ManagerUserId = request.ManagerUserId,
+ WorkEmail = request.WorkEmail,
+ WorkPhone = request.WorkPhone
+ };
+
+ var added = await _hr.AddEmploymentAsync(e, ct);
+ await _uow.CommitAsync(ct);
+ return new EmploymentResponse(added.Id, added.UserProfileId, added.StartDate, added.EndDate);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/EndEmploymentUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/EndEmploymentUseCase.cs
new file mode 100644
index 0000000..456eeeb
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/EndEmploymentUseCase.cs
@@ -0,0 +1,46 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class EndEmploymentUseCase : IEndEmploymentUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public EndEmploymentUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr,
+ IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _hr = hr;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(EmploymentEndRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ await _hr.EndEmploymentAsync(request.EmploymentId, request.EndDate, ct);
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryAddressUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryAddressUseCase.cs
new file mode 100644
index 0000000..765f4ce
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryAddressUseCase.cs
@@ -0,0 +1,46 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class SetPrimaryAddressUseCase : ISetPrimaryAddressUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public SetPrimaryAddressUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr,
+ IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _hr = hr;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(SetPrimaryAddressRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ await _hr.SetPrimaryAddressAsync(request.UserProfileId, request.AddressId, ct);
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryBankAccountUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryBankAccountUseCase.cs
new file mode 100644
index 0000000..968d6a3
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryBankAccountUseCase.cs
@@ -0,0 +1,46 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class SetPrimaryBankAccountUseCase : ISetPrimaryBankAccountUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public SetPrimaryBankAccountUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr,
+ IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _hr = hr;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(SetPrimaryBankAccountRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ await _hr.SetPrimaryBankAccountAsync(request.UserProfileId, request.BankAccountId, ct);
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryEmergencyContactUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryEmergencyContactUseCase.cs
new file mode 100644
index 0000000..7960b14
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryEmergencyContactUseCase.cs
@@ -0,0 +1,46 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class SetPrimaryEmergencyContactUseCase : ISetPrimaryEmergencyContactUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public SetPrimaryEmergencyContactUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr,
+ IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _hr = hr;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(SetPrimaryEmergencyContactRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return false;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ await _hr.SetPrimaryEmergencyContactAsync(request.UserProfileId, request.ContactId, ct);
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/UpsertUserProfileUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/UpsertUserProfileUseCase.cs
new file mode 100644
index 0000000..71e898f
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/HumanResources/UpsertUserProfileUseCase.cs
@@ -0,0 +1,59 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfile;
+using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert;
+using AMREZ.EOP.Domain.Entities.HumanResources;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.HumanResources;
+
+public sealed class UpsertUserProfileUseCase : IUpsertUserProfileUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IUnitOfWork _uow;
+ private readonly IUserProfileRepository _hr;
+ private readonly IHttpContextAccessor _http;
+
+ public UpsertUserProfileUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr,
+ IHttpContextAccessor http)
+ {
+ _resolver = r;
+ _uow = uow;
+ _hr = hr;
+ _http = http;
+ }
+
+ public async Task ExecuteAsync(UserProfileUpsertRequest request,
+ CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var current = await _hr.GetByUserIdAsync(request.UserId, ct);
+ var entity = current ?? new UserProfile { UserId = request.UserId };
+ entity.FirstName = request.FirstName;
+ entity.LastName = request.LastName;
+ entity.MiddleName = request.MiddleName;
+ entity.Nickname = request.Nickname;
+ entity.DateOfBirth = request.DateOfBirth;
+ entity.Gender = request.Gender;
+
+ await _hr.UpsertAsync(entity, ct);
+ await _uow.CommitAsync(ct);
+
+ return new UserProfileResponse(entity.Id, entity.UserId, entity.FirstName, entity.LastName);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/AddBaseDomainUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/AddBaseDomainUseCase.cs
new file mode 100644
index 0000000..93ff51b
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/AddBaseDomainUseCase.cs
@@ -0,0 +1,46 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class AddBaseDomainUseCase : IAddBaseDomainUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+
+ public AddBaseDomainUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo)
+ { _resolver = resolver; _http = http; _uow = uow; _repo = repo; }
+
+ public async Task ExecuteAsync(AddBaseDomainRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+
+ var ctx = _resolver.Resolve(http, hint: null);
+ if (ctx is null) return null;
+
+ var baseDomain = (request.BaseDomain ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(baseDomain)) return null;
+
+ await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var ok = await _repo.AddBaseDomainAsync(baseDomain, ct); // repo ดึง target tenant จาก X-Tenant/context
+ if (!ok) { await _uow.RollbackAsync(ct); return null; }
+
+ await _uow.CommitAsync(ct);
+ return new AddBaseDomainResponse(baseDomain);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/CreateTenantUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/CreateTenantUseCase.cs
new file mode 100644
index 0000000..a85f4e6
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/CreateTenantUseCase.cs
@@ -0,0 +1,77 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant;
+using AMREZ.EOP.Domain.Entities.Tenancy;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class CreateTenantUseCase : ICreateTenantUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+ private readonly ITenantProvisioner _provisioner;
+
+
+ public CreateTenantUseCase(
+ ITenantResolver resolver,
+ IHttpContextAccessor http,
+ IUnitOfWork uow,
+ ITenantRepository repo,
+ ITenantProvisioner provisioner
+ )
+ {
+ _resolver = resolver;
+ _http = http;
+ _uow = uow;
+ _repo = repo;
+ _provisioner = provisioner;
+ }
+
+ public async Task ExecuteAsync(CreateTenantRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var tenant = _resolver.Resolve(http, request);
+ if (tenant is null) return null;
+
+ await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var key = request.TenantKey.Trim().ToLowerInvariant();
+ if (await _repo.TenantExistsAsync(key, ct))
+ {
+ await _uow.RollbackAsync(ct);
+ return null;
+ }
+
+ var row = new TenantConfig
+ {
+ TenantKey = key,
+ Schema = string.IsNullOrWhiteSpace(request.Schema) ? null : request.Schema!.Trim(),
+ ConnectionString = string.IsNullOrWhiteSpace(request.ConnectionString) ? null : request.ConnectionString!.Trim(),
+ Mode = request.Mode,
+ IsActive = request.IsActive,
+ UpdatedAtUtc = DateTimeOffset.UtcNow
+ };
+
+ var ok = await _repo.CreateAsync(row, ct);
+ if (!ok) { await _uow.RollbackAsync(ct); return null; }
+
+ await _uow.CommitAsync(ct);
+
+ await _provisioner.ProvisionAsync(key, ct);
+
+ return new CreateTenantResponse(key);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/DeleteTenantUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/DeleteTenantUseCase.cs
new file mode 100644
index 0000000..0ab498e
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/DeleteTenantUseCase.cs
@@ -0,0 +1,43 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class DeleteTenantUseCase : IDeleteTenantUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+
+ public DeleteTenantUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo)
+ { _resolver = resolver; _http = http; _uow = uow; _repo = repo; }
+
+ public async Task ExecuteAsync(string tenantKey, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var key = (tenantKey ?? string.Empty).Trim().ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(key)) return false;
+
+ var ctx = _resolver.Resolve(http, hint: null);
+ if (ctx is null) return false;
+
+ await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var ok = await _repo.DeleteAsync(key, ct);
+ if (!ok) { await _uow.RollbackAsync(ct); return false; }
+ await _uow.CommitAsync(ct);
+ return true;
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/ListDomainsUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/ListDomainsUseCase.cs
new file mode 100644
index 0000000..10513d2
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/ListDomainsUseCase.cs
@@ -0,0 +1,45 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class ListDomainsUseCase : IListDomainsUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+
+ public ListDomainsUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo)
+ { _resolver = resolver; _http = http; _uow = uow; _repo = repo; }
+
+ public async Task ExecuteAsync(ListDomainsRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var key = string.IsNullOrWhiteSpace(request?.TenantKey) ? null : request!.TenantKey!.Trim().ToLowerInvariant();
+
+ var ctx = _resolver.Resolve(http, hint: null);
+ if (ctx is null) return null;
+
+ await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var items = (await _repo.ListDomainsAsync(key, ct))
+ .Select(x => new DomainDto(x.Domain, x.TenantKey, x.IsPlatformBaseDomain, x.IsActive, x.UpdatedAtUtc))
+ .ToList();
+
+ await _uow.CommitAsync(ct);
+ return new ListDomainsResponse(items);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/ListTenantsUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/ListTenantsUseCase.cs
new file mode 100644
index 0000000..5fdd4cc
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/ListTenantsUseCase.cs
@@ -0,0 +1,43 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class ListTenantsUseCase : IListTenantsUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+
+ public ListTenantsUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo)
+ { _resolver = resolver; _http = http; _uow = uow; _repo = repo; }
+
+ public async Task ExecuteAsync(ListTenantsRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+ var ctx = _resolver.Resolve(http, hint: null);
+ if (ctx is null) return null;
+
+ await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var items = (await _repo.ListTenantsAsync(ct))
+ .Select(x => new TenantDto(x.TenantKey, x.Schema, x.ConnectionString, x.Mode, x.IsActive, x.UpdatedAtUtc))
+ .ToList();
+
+ await _uow.CommitAsync(ct);
+ return new ListTenantsResponse(items);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/MapDomainUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/MapDomainUseCase.cs
new file mode 100644
index 0000000..0ca651c
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/MapDomainUseCase.cs
@@ -0,0 +1,47 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class MapDomainUseCase : IMapDomainUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+
+ public MapDomainUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo)
+ { _resolver = resolver; _http = http; _uow = uow; _repo = repo; }
+
+ public async Task ExecuteAsync(MapDomainRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+
+ var key = string.IsNullOrWhiteSpace(request?.TenantKey) ? null : request!.TenantKey!.Trim().ToLowerInvariant();
+ var domain = (request?.Domain ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(domain)) return null;
+
+ var ctx = _resolver.Resolve(http, hint: null);
+ if (ctx is null) return null;
+
+ await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var ok = await _repo.MapDomainAsync(domain, key, ct);
+ if (!ok) { await _uow.RollbackAsync(ct); return null; }
+
+ await _uow.CommitAsync(ct);
+ return new MapDomainResponse(domain, key);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/RemoveBaseDomainUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/RemoveBaseDomainUseCase.cs
new file mode 100644
index 0000000..8934b66
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/RemoveBaseDomainUseCase.cs
@@ -0,0 +1,47 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class RemoveBaseDomainUseCase : IRemoveBaseDomainUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+
+ public RemoveBaseDomainUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo)
+ { _resolver = resolver; _http = http; _uow = uow; _repo = repo; }
+
+ public async Task ExecuteAsync(RemoveBaseDomainRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+
+ var key = string.IsNullOrWhiteSpace(request?.TenantKey) ? null : request!.TenantKey!.Trim().ToLowerInvariant();
+ var baseDomain = (request?.BaseDomain ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(baseDomain)) return null;
+
+ var ctx = _resolver.Resolve(http, hint: null);
+ if (ctx is null) return null;
+
+ await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var ok = await _repo.RemoveBaseDomainAsync(baseDomain, ct);
+ if (!ok) { await _uow.RollbackAsync(ct); return null; }
+
+ await _uow.CommitAsync(ct);
+ return new RemoveBaseDomainResponse(baseDomain);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/UnmapDomainUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/UnmapDomainUseCase.cs
new file mode 100644
index 0000000..8f51af6
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/UnmapDomainUseCase.cs
@@ -0,0 +1,47 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class UnmapDomainUseCase : IUnmapDomainUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+
+ public UnmapDomainUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo)
+ { _resolver = resolver; _http = http; _uow = uow; _repo = repo; }
+
+ public async Task ExecuteAsync(UnmapDomainRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+
+ var key = string.IsNullOrWhiteSpace(request?.TenantKey) ? null : request!.TenantKey!.Trim().ToLowerInvariant();
+ var domain = (request?.Domain ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(domain)) return null;
+
+ var ctx = _resolver.Resolve(http, hint: null);
+ if (ctx is null) return null;
+
+ await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var ok = await _repo.UnmapDomainAsync(domain, ct);
+ if (!ok) { await _uow.RollbackAsync(ct); return null; }
+
+ await _uow.CommitAsync(ct);
+ return new UnmapDomainResponse(domain);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/UpdateTenantUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/UpdateTenantUseCase.cs
new file mode 100644
index 0000000..f3fc9f6
--- /dev/null
+++ b/AMREZ.EOP.Application/UseCases/Tenancy/UpdateTenantUseCase.cs
@@ -0,0 +1,58 @@
+using System.Data;
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant;
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Application.UseCases.Tenancy;
+
+public sealed class UpdateTenantUseCase : IUpdateTenantUseCase
+{
+ private readonly ITenantResolver _resolver;
+ private readonly IHttpContextAccessor _http;
+ private readonly IUnitOfWork _uow;
+ private readonly ITenantRepository _repo;
+
+ public UpdateTenantUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo)
+ { _resolver = resolver; _http = http; _uow = uow; _repo = repo; }
+
+ public async Task ExecuteAsync(UpdateTenantRequest request, CancellationToken ct = default)
+ {
+ var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
+
+ var key = (request?.TenantKey ?? string.Empty).Trim().ToLowerInvariant();
+ if (string.IsNullOrWhiteSpace(key)) return null;
+
+ var ctx = _resolver.Resolve(http, hint: null);
+ if (ctx is null) return null;
+
+ await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct);
+ try
+ {
+ var current = await _repo.GetAsync(key, ct);
+ if (current is null) { await _uow.RollbackAsync(ct); return null; } // 404
+
+ if (request!.IfUnmodifiedSince.HasValue && current.UpdatedAtUtc > request.IfUnmodifiedSince.Value)
+ { await _uow.RollbackAsync(ct); return null; } // 412
+
+ current.Schema = request.Schema is null ? current.Schema : (string.IsNullOrWhiteSpace(request.Schema) ? null : request.Schema.Trim());
+ current.ConnectionString = request.ConnectionString is null ? current.ConnectionString : (string.IsNullOrWhiteSpace(request.ConnectionString) ? null : request.ConnectionString.Trim());
+ if (request.Mode.HasValue) current.Mode = request.Mode.Value;
+ if (request.IsActive.HasValue) current.IsActive = request.IsActive.Value;
+ current.UpdatedAtUtc = DateTimeOffset.UtcNow;
+
+ var ok = await _repo.UpdateAsync(current, request.IfUnmodifiedSince, ct);
+ if (!ok) { await _uow.RollbackAsync(ct); return null; }
+
+ await _uow.CommitAsync(ct);
+ return new UpdateTenantResponse(current.TenantKey, current.UpdatedAtUtc);
+ }
+ catch
+ {
+ await _uow.RollbackAsync(ct);
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/AMREZ.EOP.Contracts.csproj b/AMREZ.EOP.Contracts/AMREZ.EOP.Contracts.csproj
new file mode 100644
index 0000000..d2e9fb9
--- /dev/null
+++ b/AMREZ.EOP.Contracts/AMREZ.EOP.Contracts.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/AddEmailIdentity/AddEmailIdentityRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/AddEmailIdentity/AddEmailIdentityRequest.cs
new file mode 100644
index 0000000..57fc618
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/AddEmailIdentity/AddEmailIdentityRequest.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity;
+
+public sealed class AddEmailIdentityRequest
+{
+ public Guid UserId { get; set; }
+ public string Email { get; set; } = default!;
+ public bool IsPrimary { get; set; } = true;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/ChangePassword/ChangePasswordRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/ChangePassword/ChangePasswordRequest.cs
new file mode 100644
index 0000000..3be2d5b
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/ChangePassword/ChangePasswordRequest.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword;
+
+public sealed class ChangePasswordRequest
+{
+ public Guid UserId { get; set; }
+ public string OldPassword { get; set; } = default!;
+ public string NewPassword { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/DisableMfa/DisableMfaRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/DisableMfa/DisableMfaRequest.cs
new file mode 100644
index 0000000..00e7cd5
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/DisableMfa/DisableMfaRequest.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa;
+
+public sealed class DisableMfaRequest
+{
+ public Guid FactorId { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpRequest.cs
new file mode 100644
index 0000000..758fbe3
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpRequest.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
+
+public sealed class EnableTotpRequest
+{
+ public Guid UserId { get; set; }
+ public string Label { get; set; } = default!;
+ public string Secret { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpResponse.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpResponse.cs
new file mode 100644
index 0000000..de26326
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpResponse.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
+
+public sealed record EnableTotpResponse(
+ Guid FactorId,
+ string Label
+);
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginRequest.cs
new file mode 100644
index 0000000..4f2e280
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginRequest.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.Login;
+
+public sealed class LoginRequest
+{
+ public string? Tenant { get; set; }
+ public string Email { get; set; } = default!;
+ public string Password { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginResponse.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginResponse.cs
new file mode 100644
index 0000000..882aff1
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginResponse.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.Login;
+
+public sealed record LoginResponse(
+ Guid UserId,
+ string DisplayName,
+ string Email,
+ string TenantId
+);
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Logout/LogoutRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Logout/LogoutRequest.cs
new file mode 100644
index 0000000..a679594
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Logout/LogoutRequest.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.Logout;
+
+public sealed class LogoutRequest
+{
+ public Guid UserId { get; set; }
+ public Guid SessionId { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/LogoutAll/LogoutAllRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/LogoutAll/LogoutAllRequest.cs
new file mode 100644
index 0000000..879d649
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/LogoutAll/LogoutAllRequest.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll;
+
+public sealed class LogoutAllRequest
+{
+ public Guid UserId { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterRequest.cs
new file mode 100644
index 0000000..a444822
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterRequest.cs
@@ -0,0 +1,9 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.Register;
+
+public sealed class RegisterRequest
+{
+ public string? Tenant { get; set; }
+ public string Email { get; set; } = default!;
+ public string Password{ get; set; } = default!;
+ public string Name { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterResponse.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterResponse.cs
new file mode 100644
index 0000000..088703b
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterResponse.cs
@@ -0,0 +1,3 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.Register;
+
+public sealed record RegisterResponse(Guid UserId, string Name, string Email, string Tenant);
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/VerifyEmail/VerifyEmailRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/VerifyEmail/VerifyEmailRequest.cs
new file mode 100644
index 0000000..5fcc77a
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Authentications/VerifyEmail/VerifyEmailRequest.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail;
+
+public sealed class VerifyEmailRequest
+{
+ public Guid UserId { get; set; }
+ public string Email { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContact/EmergencyContactResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContact/EmergencyContactResponse.cs
new file mode 100644
index 0000000..ba6bff7
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContact/EmergencyContactResponse.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContact;
+
+public sealed record EmergencyContactResponse(
+ Guid Id,
+ Guid UserProfileId,
+ bool IsPrimary
+);
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContactAdd/EmergencyContactAddRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContactAdd/EmergencyContactAddRequest.cs
new file mode 100644
index 0000000..aa5cf75
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContactAdd/EmergencyContactAddRequest.cs
@@ -0,0 +1,11 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd;
+
+public sealed class EmergencyContactAddRequest
+{
+ public Guid UserProfileId { get; set; }
+ public string Name { get; set; } = default!;
+ public string Relationship { get; set; } = default!;
+ public string? Phone { get; set; }
+ public string? Email { get; set; }
+ public bool IsPrimary { get; set; } = false;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddress/EmployeeAddressResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddress/EmployeeAddressResponse.cs
new file mode 100644
index 0000000..a05a2b7
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddress/EmployeeAddressResponse.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddress;
+
+public sealed record EmployeeAddressResponse(
+ Guid Id,
+ Guid UserProfileId,
+ bool IsPrimary
+);
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddressAdd/EmployeeAddressAddRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddressAdd/EmployeeAddressAddRequest.cs
new file mode 100644
index 0000000..26f4ef9
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddressAdd/EmployeeAddressAddRequest.cs
@@ -0,0 +1,18 @@
+using AMREZ.EOP.Domain.Shared.HumanResources;
+
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd;
+
+public sealed class EmployeeAddressAddRequest
+{
+ public Guid UserProfileId { get; set; }
+ public AddressType Type { get; set; } = AddressType.Home;
+
+ public string Line1 { get; set; } = default!;
+ public string? Line2 { get; set; }
+ public string City { get; set; } = default!;
+ public string? State { get; set; }
+ public string PostalCode{ get; set; } = default!;
+ public string Country { get; set; } = default!;
+
+ public bool IsPrimary { get; set; } = false;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccount/EmployeeBankAccountResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccount/EmployeeBankAccountResponse.cs
new file mode 100644
index 0000000..e058327
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccount/EmployeeBankAccountResponse.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccount;
+
+public sealed record EmployeeBankAccountResponse(
+ Guid Id,
+ Guid UserProfileId,
+ bool IsPrimary
+);
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccountAdd/EmployeeBankAccountAddRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccountAdd/EmployeeBankAccountAddRequest.cs
new file mode 100644
index 0000000..d211345
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccountAdd/EmployeeBankAccountAddRequest.cs
@@ -0,0 +1,12 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd;
+
+public sealed class EmployeeBankAccountAddRequest
+{
+ public Guid UserProfileId { get; set; }
+ public string BankName { get; set; } = default!;
+ public string AccountNumber { get; set; } = default!;
+ public string AccountHolder { get; set; } = default!;
+ public string? Branch { get; set; }
+ public string? Note { get; set; }
+ public bool IsPrimary { get; set; } = false;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/Employment/EmploymentResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/Employment/EmploymentResponse.cs
new file mode 100644
index 0000000..56ed0d6
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/Employment/EmploymentResponse.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.Employment;
+
+public sealed record EmploymentResponse(
+ Guid Id,
+ Guid UserProfileId,
+ DateTime StartDate,
+ DateTime? EndDate
+);
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentAdd/EmploymentAddRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentAdd/EmploymentAddRequest.cs
new file mode 100644
index 0000000..27ca971
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentAdd/EmploymentAddRequest.cs
@@ -0,0 +1,15 @@
+using AMREZ.EOP.Domain.Shared.HumanResources;
+
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd;
+
+public sealed class EmploymentAddRequest
+{
+ public Guid UserProfileId { get; set; }
+ public EmploymentType EmploymentType { get; set; } = EmploymentType.Permanent;
+ public DateTime StartDate { get; set; }
+ public Guid? DepartmentId { get; set; }
+ public Guid? PositionId { get; set; }
+ public Guid? ManagerUserId{ get; set; }
+ public string? WorkEmail { get; set; }
+ public string? WorkPhone { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentEnd/EmploymentEndRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentEnd/EmploymentEndRequest.cs
new file mode 100644
index 0000000..c44004c
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentEnd/EmploymentEndRequest.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd;
+
+public sealed class EmploymentEndRequest
+{
+ public Guid EmploymentId { get; set; }
+ public DateTime EndDate { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryAddress/SetPrimaryAddressRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryAddress/SetPrimaryAddressRequest.cs
new file mode 100644
index 0000000..f561214
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryAddress/SetPrimaryAddressRequest.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress;
+
+public sealed class SetPrimaryAddressRequest
+{
+ public Guid UserProfileId { get; set; }
+ public Guid AddressId { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryBankAccount/SetPrimaryBankAccountRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryBankAccount/SetPrimaryBankAccountRequest.cs
new file mode 100644
index 0000000..d2211fa
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryBankAccount/SetPrimaryBankAccountRequest.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount;
+
+public sealed class SetPrimaryBankAccountRequest
+{
+ public Guid UserProfileId { get; set; }
+ public Guid BankAccountId { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryEmergencyContact/SetPrimaryEmergencyContactRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryEmergencyContact/SetPrimaryEmergencyContactRequest.cs
new file mode 100644
index 0000000..9eb20ef
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryEmergencyContact/SetPrimaryEmergencyContactRequest.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact;
+
+public sealed class SetPrimaryEmergencyContactRequest
+{
+ public Guid UserProfileId { get; set; }
+ public Guid ContactId { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfile/UserProfileResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfile/UserProfileResponse.cs
new file mode 100644
index 0000000..67a57e3
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfile/UserProfileResponse.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfile;
+
+public sealed record UserProfileResponse(
+ Guid Id,
+ Guid UserId,
+ string FirstName,
+ string LastName
+);
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfileUpsert/UserProfileUpsertRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfileUpsert/UserProfileUpsertRequest.cs
new file mode 100644
index 0000000..ec9eb7e
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfileUpsert/UserProfileUpsertRequest.cs
@@ -0,0 +1,14 @@
+using AMREZ.EOP.Domain.Shared.HumanResources;
+
+namespace AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert;
+
+public sealed class UserProfileUpsertRequest
+{
+ public Guid UserId { get; set; }
+ public string FirstName { get; set; } = default!;
+ public string LastName { get; set; } = default!;
+ public string? MiddleName { get; set; }
+ public string? Nickname { get; set; }
+ public DateTime? DateOfBirth { get; set; }
+ public Gender? Gender { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainRequest.cs
new file mode 100644
index 0000000..ccbbf80
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainRequest.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain;
+
+public sealed class AddBaseDomainRequest
+{
+ public string TenantKey { get; set; } = default!;
+ public string BaseDomain { get; set; } = default!;
+ public string? Tenant { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainResponse.cs
new file mode 100644
index 0000000..3f2b4ca
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainResponse.cs
@@ -0,0 +1,5 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain;
+
+public sealed record AddBaseDomainResponse(
+ string BaseDomain
+ );
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantRequest.cs
new file mode 100644
index 0000000..18c4e9e
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantRequest.cs
@@ -0,0 +1,14 @@
+using AMREZ.EOP.Domain.Shared.Tenancy;
+
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant;
+
+public sealed class CreateTenantRequest
+{
+ public string TenantKey { get; set; } = default!;
+ public string? Schema { get; set; }
+ public string? ConnectionString { get; set; }
+ public TenantMode Mode { get; set; } = TenantMode.Rls;
+ public bool IsActive { get; set; } = true;
+
+ public string? Tenant { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantResponse.cs
new file mode 100644
index 0000000..3866616
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantResponse.cs
@@ -0,0 +1,5 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant;
+
+public sealed record CreateTenantResponse(
+ string TenantKey
+ );
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsRequest.cs
new file mode 100644
index 0000000..9c619cc
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsRequest.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains;
+
+public sealed class ListDomainsRequest
+{
+ public string? TenantKey { get; set; }
+ public string? Tenant { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsResponse.cs
new file mode 100644
index 0000000..bc4c109
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsResponse.cs
@@ -0,0 +1,12 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains;
+
+public sealed record DomainDto(
+ string Domain,
+ string? TenantKey,
+ bool IsPlatformBaseDomain,
+ bool IsActive,
+ DateTimeOffset UpdatedAtUtc);
+
+public sealed record ListDomainsResponse(
+ IReadOnlyList Items
+ );
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsRequest.cs
new file mode 100644
index 0000000..0a3bba0
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsRequest.cs
@@ -0,0 +1,7 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants;
+
+public sealed class ListTenantsRequest
+{
+ public string TenantKey { get; set; } = default!;
+ public string? Tenant { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsResponse.cs
new file mode 100644
index 0000000..45ea1d6
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsResponse.cs
@@ -0,0 +1,15 @@
+using AMREZ.EOP.Domain.Shared.Tenancy;
+
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants;
+
+public sealed record TenantDto(
+ string TenantKey,
+ string? Schema,
+ string? ConnectionString,
+ TenantMode Mode,
+ bool IsActive,
+ DateTimeOffset UpdatedAtUtc);
+
+public sealed record ListTenantsResponse(
+ IReadOnlyList Items
+ );
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainRequest.cs
new file mode 100644
index 0000000..737037b
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainRequest.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain;
+
+public sealed class MapDomainRequest
+{
+ public string TenantKey { get; set; } = default!;
+ public string Domain { get; set; } = default!;
+ public string? Tenant { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainResponse.cs
new file mode 100644
index 0000000..a393bfa
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainResponse.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain;
+
+public sealed record MapDomainResponse(
+ string Domain,
+ string TenantKey
+ );
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainRequest.cs
new file mode 100644
index 0000000..ebf39a2
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainRequest.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain;
+
+public sealed class RemoveBaseDomainRequest
+{
+ public string TenantKey { get; set; } = default!;
+ public string BaseDomain { get; set; } = default!;
+ public string? Tenant { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainResponse.cs
new file mode 100644
index 0000000..cee8ee8
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainResponse.cs
@@ -0,0 +1,5 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain;
+
+public sealed record RemoveBaseDomainResponse(
+ string BaseDomain
+ );
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainRequest.cs
new file mode 100644
index 0000000..b2e12d2
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainRequest.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain;
+
+public sealed class UnmapDomainRequest
+{
+ public string TenantKey { get; set; } = default!;
+ public string Domain { get; set; } = default!;
+ public string? Tenant { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainResponse.cs
new file mode 100644
index 0000000..42a65eb
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainResponse.cs
@@ -0,0 +1,5 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain;
+
+public sealed record UnmapDomainResponse(
+ string Domain
+ );
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantRequest.cs
new file mode 100644
index 0000000..9d9eca6
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantRequest.cs
@@ -0,0 +1,14 @@
+using AMREZ.EOP.Domain.Shared.Tenancy;
+
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant;
+
+public sealed class UpdateTenantRequest
+{
+ public string TenantKey { get; set; } = default!;
+ public string? Schema { get; set; }
+ public string? ConnectionString { get; set; }
+ public TenantMode? Mode { get; set; }
+ public bool? IsActive { get; set; }
+ public DateTimeOffset? IfUnmodifiedSince { get; set; }
+ public string? Tenant { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantResponse.cs
new file mode 100644
index 0000000..325ee13
--- /dev/null
+++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantResponse.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant;
+
+public sealed record UpdateTenantResponse(
+ string TenantKey,
+ DateTimeOffset UpdatedAtUtc
+ );
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/AMREZ.EOP.Domain.csproj b/AMREZ.EOP.Domain/AMREZ.EOP.Domain.csproj
new file mode 100644
index 0000000..52b6ef7
--- /dev/null
+++ b/AMREZ.EOP.Domain/AMREZ.EOP.Domain.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/Permission.cs b/AMREZ.EOP.Domain/Entities/Authentications/Permission.cs
new file mode 100644
index 0000000..8e0b922
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/Permission.cs
@@ -0,0 +1,12 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class Permission : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public string Code { get; set; } = default!; // e.g. "auth:session:read"
+ public string Name { get; set; } = default!;
+
+ public ICollection RolePermissions { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/Role.cs b/AMREZ.EOP.Domain/Entities/Authentications/Role.cs
new file mode 100644
index 0000000..bad792d
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/Role.cs
@@ -0,0 +1,13 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class Role : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public string Code { get; set; } = default!; // system code, unique per tenant
+ public string Name { get; set; } = default!;
+
+ public ICollection UserRoles { get; set; } = new List();
+ public ICollection RolePermissions { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/RolePermission.cs b/AMREZ.EOP.Domain/Entities/Authentications/RolePermission.cs
new file mode 100644
index 0000000..3580fec
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/RolePermission.cs
@@ -0,0 +1,13 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class RolePermission : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid RoleId { get; set; }
+ public Guid PermissionId { get; set; }
+
+ public Role Role { get; set; } = default!;
+ public Permission Permission { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/User.cs b/AMREZ.EOP.Domain/Entities/Authentications/User.cs
new file mode 100644
index 0000000..9a77933
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/User.cs
@@ -0,0 +1,24 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class User : BaseEntity
+{
+ public Guid TenantId { get; set; }
+
+ public string PasswordHash { get; set; } = default!;
+ public bool IsActive { get; set; } = true;
+
+ public int AccessFailedCount { get; set; }
+ public DateTimeOffset? LockoutEndUtc { get; set; }
+ public bool MfaEnabled { get; set; }
+ public string? SecurityStamp { get; set; }
+
+ public ICollection Identities { get; set; } = new List();
+ public ICollection MfaFactors { get; set; } = new List();
+ public ICollection Sessions { get; set; } = new List();
+ public ICollection PasswordHistories { get; set; } = new List();
+ public ICollection ExternalAccounts { get; set; } = new List();
+
+ public ICollection UserRoles { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserExternalAccount.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserExternalAccount.cs
new file mode 100644
index 0000000..fab642c
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/UserExternalAccount.cs
@@ -0,0 +1,17 @@
+using AMREZ.EOP.Domain.Entities.Common;
+using AMREZ.EOP.Domain.Shared._Users;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class UserExternalAccount : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+
+ public ExternalProvider Provider { get; set; }
+ public string Subject { get; set; } = default!; // provider UID/sub
+ public string? Email { get; set; }
+ public DateTimeOffset LinkedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public User User { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserIdentity.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserIdentity.cs
new file mode 100644
index 0000000..9e1e90a
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/UserIdentity.cs
@@ -0,0 +1,17 @@
+using AMREZ.EOP.Domain.Entities.Common;
+using AMREZ.EOP.Domain.Shared._Users;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class UserIdentity : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+
+ public IdentityType Type { get; set; }
+ public string Identifier { get; set; } = default!;
+ public bool IsPrimary { get; set; }
+ public DateTimeOffset? VerifiedAt { get; set; }
+
+ public User User { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserMfaFactor.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserMfaFactor.cs
new file mode 100644
index 0000000..15de337
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/UserMfaFactor.cs
@@ -0,0 +1,25 @@
+using AMREZ.EOP.Domain.Entities.Common;
+using AMREZ.EOP.Domain.Shared._Users;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class UserMfaFactor : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+
+ public MfaType Type { get; set; }
+ public string? Label { get; set; }
+
+ public string? Secret { get; set; } // TOTP secret (encrypt at rest)
+ public string? PhoneE164 { get; set; }
+ public string? Email { get; set; }
+ public string? PublicKey { get; set; } // WebAuthn
+ public string? CredentialId { get; set; } // WebAuthn
+
+ public bool Enabled { get; set; } = true;
+ public DateTimeOffset AddedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset? LastUsedAt { get; set; }
+
+ public User User { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserPasswordHistory.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserPasswordHistory.cs
new file mode 100644
index 0000000..bba1b73
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/UserPasswordHistory.cs
@@ -0,0 +1,14 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class UserPasswordHistory : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+
+ public string PasswordHash { get; set; } = default!;
+ public DateTimeOffset ChangedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public User User { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserRole.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserRole.cs
new file mode 100644
index 0000000..696ef27
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/UserRole.cs
@@ -0,0 +1,13 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class UserRole : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+ public Guid RoleId { get; set; }
+
+ public User User { get; set; } = default!;
+ public Role Role { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserSession.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserSession.cs
new file mode 100644
index 0000000..0c75e9d
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Authentications/UserSession.cs
@@ -0,0 +1,20 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.Authentications;
+
+public sealed class UserSession : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+
+ public string RefreshTokenHash { get; set; } = default!;
+ public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset? ExpiresAt { get; set; }
+ public DateTimeOffset? RevokedAt { get; set; }
+
+ public string? DeviceId { get; set; }
+ public string? UserAgent { get; set; }
+ public string? IpAddress { get; set; }
+
+ public User User { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Common/BaseEntity.cs b/AMREZ.EOP.Domain/Entities/Common/BaseEntity.cs
new file mode 100644
index 0000000..e65932d
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Common/BaseEntity.cs
@@ -0,0 +1,13 @@
+namespace AMREZ.EOP.Domain.Entities.Common;
+
+public abstract class BaseEntity
+{
+ public Guid Id { get; set; }
+ public string TenantId { get; set; } = default!;
+
+ public DateTimeOffset CreatedAt { get; set; }
+ public string? CreatedBy { get; set; }
+ public DateTimeOffset?UpdatedAt { get; set; }
+ public string? UpdatedBy { get; set; }
+ public bool IsDeleted { get; set; } = false;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/Department.cs b/AMREZ.EOP.Domain/Entities/HumanResources/Department.cs
new file mode 100644
index 0000000..3665516
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/HumanResources/Department.cs
@@ -0,0 +1,16 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.HumanResources;
+
+public sealed class Department : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public string Code { get; set; } = default!;
+ public string Name { get; set; } = default!;
+
+ public Guid? ParentDepartmentId { get; set; }
+ public Department? Parent { get; set; }
+
+ public ICollection Children { get; set; } = new List();
+ public ICollection Profiles { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/EmergencyContact.cs b/AMREZ.EOP.Domain/Entities/HumanResources/EmergencyContact.cs
new file mode 100644
index 0000000..0dfcfae
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/HumanResources/EmergencyContact.cs
@@ -0,0 +1,18 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.HumanResources;
+
+public sealed class EmergencyContact : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserProfileId { get; set; }
+
+ public string Name { get; set; } = default!;
+ public string Relationship { get; set; } = default!;
+ public string? Phone { get; set; }
+ public string? Email { get; set; }
+
+ public bool IsPrimary { get; set; }
+
+ public UserProfile UserProfile { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeAddress.cs b/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeAddress.cs
new file mode 100644
index 0000000..9e609d5
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeAddress.cs
@@ -0,0 +1,23 @@
+using AMREZ.EOP.Domain.Entities.Common;
+using AMREZ.EOP.Domain.Shared.HumanResources;
+
+namespace AMREZ.EOP.Domain.Entities.HumanResources;
+
+public sealed class EmployeeAddress : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserProfileId { get; set; }
+
+ public AddressType Type { get; set; } = AddressType.Home;
+
+ public string Line1 { get; set; } = default!;
+ public string? Line2 { get; set; }
+ public string City { get; set; } = default!;
+ public string? State { get; set; }
+ public string PostalCode { get; set; } = default!;
+ public string Country { get; set; } = default!;
+
+ public bool IsPrimary { get; set; }
+
+ public UserProfile UserProfile { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeBankAccount.cs b/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeBankAccount.cs
new file mode 100644
index 0000000..0718853
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeBankAccount.cs
@@ -0,0 +1,18 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.HumanResources;
+
+public sealed class EmployeeBankAccount : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserProfileId { get; set; }
+
+ public string BankName { get; set; } = default!;
+ public string AccountNumber { get; set; } = default!;
+ public string AccountHolder { get; set; } = default!;
+ public string? Branch { get; set; }
+ public string? Note { get; set; }
+ public bool IsPrimary { get; set; }
+
+ public UserProfile UserProfile { get; set; } = default!;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/Employment.cs b/AMREZ.EOP.Domain/Entities/HumanResources/Employment.cs
new file mode 100644
index 0000000..0013355
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/HumanResources/Employment.cs
@@ -0,0 +1,25 @@
+using AMREZ.EOP.Domain.Entities.Common;
+using AMREZ.EOP.Domain.Shared.HumanResources;
+
+namespace AMREZ.EOP.Domain.Entities.HumanResources;
+
+public sealed class Employment : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserProfileId { get; set; }
+
+ public EmploymentType EmploymentType { get; set; } = EmploymentType.Permanent;
+ public DateTime StartDate { get; set; }
+ public DateTime? EndDate { get; set; }
+
+ public Guid? DepartmentId { get; set; }
+ public Guid? PositionId { get; set; }
+ public Guid? ManagerUserId { get; set; } // references Authentications.User
+
+ public string? WorkEmail { get; set; }
+ public string? WorkPhone { get; set; }
+
+ public UserProfile UserProfile { get; set; } = default!;
+ public Department? Department { get; set; }
+ public Position? Position { get; set; }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/Position.cs b/AMREZ.EOP.Domain/Entities/HumanResources/Position.cs
new file mode 100644
index 0000000..c363726
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/HumanResources/Position.cs
@@ -0,0 +1,13 @@
+using AMREZ.EOP.Domain.Entities.Common;
+
+namespace AMREZ.EOP.Domain.Entities.HumanResources;
+
+public sealed class Position : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public string Code { get; set; } = default!;
+ public string Title { get; set; } = default!;
+ public int? Level { get; set; }
+
+ public ICollection Profiles { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/UserProfile.cs b/AMREZ.EOP.Domain/Entities/HumanResources/UserProfile.cs
new file mode 100644
index 0000000..0e03428
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/HumanResources/UserProfile.cs
@@ -0,0 +1,26 @@
+using AMREZ.EOP.Domain.Entities.Authentications;
+using AMREZ.EOP.Domain.Entities.Common;
+using AMREZ.EOP.Domain.Shared.HumanResources;
+
+namespace AMREZ.EOP.Domain.Entities.HumanResources;
+
+public sealed class UserProfile : BaseEntity
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+
+ public string FirstName { get; set; } = default!;
+ public string LastName { get; set; } = default!;
+ public string? MiddleName { get; set; }
+ public string? Nickname { get; set; }
+
+ public DateTime? DateOfBirth { get; set; }
+ public Gender? Gender { get; set; }
+
+ public User User { get; set; } = default!;
+
+ public ICollection Employments { get; set; } = new List();
+ public ICollection Addresses { get; set; } = new List();
+ public ICollection EmergencyContacts { get; set; } = new List();
+ public ICollection BankAccounts { get; set; } = new List();
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Tenancy/TenantConfig.cs b/AMREZ.EOP.Domain/Entities/Tenancy/TenantConfig.cs
new file mode 100644
index 0000000..06d2c9c
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Tenancy/TenantConfig.cs
@@ -0,0 +1,13 @@
+using AMREZ.EOP.Domain.Shared.Tenancy;
+
+namespace AMREZ.EOP.Domain.Entities.Tenancy;
+
+public sealed class TenantConfig
+{
+ public string TenantKey { get; set; } = default!;
+ public string? Schema { get; set; }
+ public string? ConnectionString { get; set; }
+ public TenantMode Mode { get; set; } = TenantMode.Rls;
+ public bool IsActive { get; set; } = true;
+ public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Entities/Tenancy/TenantDomain.cs b/AMREZ.EOP.Domain/Entities/Tenancy/TenantDomain.cs
new file mode 100644
index 0000000..b6ffa59
--- /dev/null
+++ b/AMREZ.EOP.Domain/Entities/Tenancy/TenantDomain.cs
@@ -0,0 +1,10 @@
+namespace AMREZ.EOP.Domain.Entities.Tenancy;
+
+public class TenantDomain
+{
+ public string Domain { get; set; } = default!;
+ public string? TenantKey { get; set; }
+ public bool IsPlatformBaseDomain { get; set; } = false;
+ public bool IsActive { get; set; } = true;
+ public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/Contracts/AuthPolicies.cs b/AMREZ.EOP.Domain/Shared/Contracts/AuthPolicies.cs
new file mode 100644
index 0000000..fb39969
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/Contracts/AuthPolicies.cs
@@ -0,0 +1,6 @@
+namespace AMREZ.EOP.Domain.Shared.Contracts;
+
+public static class AuthPolicies
+{
+ public const string Scheme = "AuthCookie";
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/HumanResources/AddressType.cs b/AMREZ.EOP.Domain/Shared/HumanResources/AddressType.cs
new file mode 100644
index 0000000..cbcf8f4
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/HumanResources/AddressType.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Domain.Shared.HumanResources;
+
+public enum AddressType
+{
+ Home = 1,
+ Mailing = 2,
+ Other = 9
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/HumanResources/EmploymentType.cs b/AMREZ.EOP.Domain/Shared/HumanResources/EmploymentType.cs
new file mode 100644
index 0000000..de95d0d
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/HumanResources/EmploymentType.cs
@@ -0,0 +1,9 @@
+namespace AMREZ.EOP.Domain.Shared.HumanResources;
+
+public enum EmploymentType
+{
+ Permanent = 1,
+ Contract = 2,
+ PartTime = 3,
+ Intern = 4
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/HumanResources/Gender.cs b/AMREZ.EOP.Domain/Shared/HumanResources/Gender.cs
new file mode 100644
index 0000000..4429a0d
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/HumanResources/Gender.cs
@@ -0,0 +1,9 @@
+namespace AMREZ.EOP.Domain.Shared.HumanResources;
+
+public enum Gender
+{
+ Unknown = 0,
+ Male = 1,
+ Female = 2,
+ Other = 3
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/Tenancy/HttpContextTenantExtensions.cs b/AMREZ.EOP.Domain/Shared/Tenancy/HttpContextTenantExtensions.cs
new file mode 100644
index 0000000..91f91aa
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/Tenancy/HttpContextTenantExtensions.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Http;
+
+namespace AMREZ.EOP.Domain.Shared.Tenancy;
+
+public static class HttpContextTenantExtensions
+{
+ public static string? GetTargetTenantKey(this HttpContext http)
+ {
+ return http.Items.TryGetValue("TargetTenantKey", out var v)
+ ? v as string
+ : http.Request.Headers["X-Tenant"].ToString()?.Trim().ToLowerInvariant();
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/Tenancy/TenantMode.cs b/AMREZ.EOP.Domain/Shared/Tenancy/TenantMode.cs
new file mode 100644
index 0000000..27bb3d9
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/Tenancy/TenantMode.cs
@@ -0,0 +1,8 @@
+namespace AMREZ.EOP.Domain.Shared.Tenancy;
+
+public enum TenantMode
+{
+ Rls,
+ Schema,
+ Database
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/๊Users/ExternalProvider.cs b/AMREZ.EOP.Domain/Shared/๊Users/ExternalProvider.cs
new file mode 100644
index 0000000..efe8d1b
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/๊Users/ExternalProvider.cs
@@ -0,0 +1,11 @@
+namespace AMREZ.EOP.Domain.Shared._Users;
+
+public enum ExternalProvider
+{
+ Google = 1,
+ Apple = 2,
+ Microsoft = 3,
+ Facebook = 4,
+ Line = 5,
+ Other = 9
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/๊Users/IdentityType.cs b/AMREZ.EOP.Domain/Shared/๊Users/IdentityType.cs
new file mode 100644
index 0000000..314ce68
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/๊Users/IdentityType.cs
@@ -0,0 +1,9 @@
+namespace AMREZ.EOP.Domain.Shared._Users;
+
+public enum IdentityType
+{
+ Email = 1,
+ Phone = 2,
+ Username = 3,
+ Other = 9
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Domain/Shared/๊Users/MfaType.cs b/AMREZ.EOP.Domain/Shared/๊Users/MfaType.cs
new file mode 100644
index 0000000..189a4ed
--- /dev/null
+++ b/AMREZ.EOP.Domain/Shared/๊Users/MfaType.cs
@@ -0,0 +1,9 @@
+namespace AMREZ.EOP.Domain.Shared._Users;
+
+public enum MfaType
+{
+ Totp = 1,
+ Sms = 2,
+ EmailOtp = 3,
+ WebAuthn = 4
+}
diff --git a/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj b/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj
new file mode 100644
index 0000000..14593c7
--- /dev/null
+++ b/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AMREZ.EOP.Infrastructures/Data/AppDbContext.cs b/AMREZ.EOP.Infrastructures/Data/AppDbContext.cs
new file mode 100644
index 0000000..85bb717
--- /dev/null
+++ b/AMREZ.EOP.Infrastructures/Data/AppDbContext.cs
@@ -0,0 +1,365 @@
+using AMREZ.EOP.Domain.Entities.Authentications;
+using AMREZ.EOP.Domain.Entities.Common;
+using AMREZ.EOP.Domain.Entities.HumanResources;
+using AMREZ.EOP.Domain.Entities.Tenancy;
+using Microsoft.EntityFrameworkCore;
+
+namespace AMREZ.EOP.Infrastructures.Data;
+
+public class AppDbContext : DbContext
+{
+ public AppDbContext(DbContextOptions options) : base(options) { }
+
+ // ===== Auth =====
+ public DbSet Users => Set();
+ public DbSet UserIdentities => Set();
+ public DbSet UserMfaFactors => Set();
+ public DbSet UserSessions => Set();
+ public DbSet UserPasswordHistories => Set();
+ public DbSet UserExternalAccounts => Set();
+ public DbSet Roles => Set();
+ public DbSet Permissions => Set();
+ public DbSet UserRoles => Set();
+ public DbSet RolePermissions => Set();
+
+ // ===== HR =====
+ public DbSet UserProfiles => Set();
+ public DbSet Departments => Set();
+ public DbSet Positions => Set();
+ public DbSet Employments => Set();
+ public DbSet EmployeeAddresses => Set();
+ public DbSet EmergencyContacts => Set();
+ public DbSet EmployeeBankAccounts => Set();
+
+ // ===== Tenancy (meta) =====
+ public DbSet Tenants => Set();
+ public DbSet TenantDomains => Set();
+
+ protected override void OnModelCreating(ModelBuilder model)
+ {
+ // ====== Global Tenancy Config (meta schema) — ไม่สืบทอด BaseEntity ======
+ model.Entity(b =>
+ {
+ b.ToTable("tenants", schema: "meta");
+ b.HasKey(x => x.TenantKey);
+ b.Property(x => x.TenantKey).HasMaxLength(128).IsRequired();
+ b.Property(x => x.Schema).HasMaxLength(128);
+ b.Property(x => x.ConnectionString);
+ b.Property(x => x.Mode).IsRequired();
+ b.Property(x => x.IsActive).HasDefaultValue(true);
+ b.Property(x => x.UpdatedAtUtc)
+ .HasColumnName("updated_at_utc")
+ .HasDefaultValueSql("now() at time zone 'utc'");
+ b.HasIndex(x => x.IsActive);
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("tenant_domains", schema: "meta");
+ b.HasKey(x => x.Domain);
+ b.Property(x => x.Domain).HasMaxLength(253).IsRequired();
+ b.Property(x => x.TenantKey).HasMaxLength(128);
+ b.Property(x => x.IsPlatformBaseDomain).HasDefaultValue(false);
+ b.Property(x => x.IsActive).HasDefaultValue(true);
+ b.Property(x => x.UpdatedAtUtc)
+ .HasColumnName("updated_at_utc")
+ .HasDefaultValueSql("now() at time zone 'utc'");
+
+ b.HasIndex(x => x.TenantKey);
+ b.HasIndex(x => x.IsPlatformBaseDomain);
+ b.HasIndex(x => x.IsActive);
+
+ b.HasOne()
+ .WithMany()
+ .HasForeignKey(x => x.TenantKey)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ // ====== Auth ======
+ model.Entity(b =>
+ {
+ b.ToTable("users");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.PasswordHash).IsRequired();
+ b.Property(x => x.IsActive).HasDefaultValue(true);
+
+ b.Property(x => x.AccessFailedCount).HasDefaultValue(0);
+ b.Property(x => x.MfaEnabled).HasDefaultValue(false);
+
+ b.HasMany(x => x.Identities)
+ .WithOne(i => i.User)
+ .HasForeignKey(i => i.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasMany(x => x.MfaFactors)
+ .WithOne(i => i.User)
+ .HasForeignKey(i => i.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasMany(x => x.Sessions)
+ .WithOne(s => s.User)
+ .HasForeignKey(s => s.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasMany(x => x.PasswordHistories)
+ .WithOne(ph => ph.User)
+ .HasForeignKey(ph => ph.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasMany(x => x.ExternalAccounts)
+ .WithOne(ea => ea.User)
+ .HasForeignKey(ea => ea.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("user_identities");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Type).IsRequired();
+ b.Property(x => x.Identifier).IsRequired().HasMaxLength(256);
+ b.Property(x => x.IsPrimary).HasDefaultValue(false);
+
+ b.HasIndex(x => new { x.TenantId, x.Type, x.Identifier })
+ .IsUnique();
+
+ b.HasIndex(x => new { x.TenantId, x.UserId, x.Type, x.IsPrimary })
+ .HasDatabaseName("ix_user_identity_primary_per_type");
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("user_mfa_factors");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Type).IsRequired();
+ b.Property(x => x.Enabled).HasDefaultValue(true);
+
+ b.HasIndex(x => new { x.TenantId, x.UserId });
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("user_sessions");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.RefreshTokenHash).IsRequired();
+ b.HasIndex(x => new { x.TenantId, x.UserId });
+ b.HasIndex(x => new { x.TenantId, x.DeviceId });
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("user_password_histories");
+ b.HasKey(x => x.Id);
+ b.Property(x => x.PasswordHash).IsRequired();
+ b.HasIndex(x => new { x.TenantId, x.UserId, x.ChangedAt });
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("user_external_accounts");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Provider).IsRequired();
+ b.Property(x => x.Subject).IsRequired();
+
+ b.HasIndex(x => new { x.TenantId, x.Provider, x.Subject })
+ .IsUnique();
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("roles");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Code).IsRequired().HasMaxLength(128);
+ b.Property(x => x.Name).IsRequired().HasMaxLength(256);
+
+ b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("permissions");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Code).IsRequired().HasMaxLength(256);
+ b.Property(x => x.Name).IsRequired().HasMaxLength(256);
+
+ b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("user_roles");
+ b.HasKey(x => x.Id);
+
+ b.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique();
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("role_permissions");
+ b.HasKey(x => x.Id);
+
+ b.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique();
+ });
+
+ // ====== HR ======
+ model.Entity(b =>
+ {
+ b.ToTable("user_profiles");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.FirstName).IsRequired().HasMaxLength(128);
+ b.Property(x => x.LastName).IsRequired().HasMaxLength(128);
+
+ b.HasOne(x => x.User)
+ .WithOne()
+ .HasForeignKey(x => x.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasIndex(x => new { x.TenantId, x.UserId }).IsUnique();
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("departments");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Code).IsRequired().HasMaxLength(64);
+ b.Property(x => x.Name).IsRequired().HasMaxLength(256);
+
+ b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
+
+ b.HasOne(x => x.Parent)
+ .WithMany(x => x.Children)
+ .HasForeignKey(x => x.ParentDepartmentId)
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("positions");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Code).IsRequired().HasMaxLength(64);
+ b.Property(x => x.Title).IsRequired().HasMaxLength(256);
+
+ b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("employments");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.EmploymentType).IsRequired();
+ b.Property(x => x.StartDate).IsRequired();
+
+ b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.StartDate });
+
+ b.HasOne(x => x.UserProfile)
+ .WithMany(p => p.Employments)
+ .HasForeignKey(x => x.UserProfileId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne(x => x.Department)
+ .WithMany()
+ .HasForeignKey(x => x.DepartmentId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne(x => x.Position)
+ .WithMany()
+ .HasForeignKey(x => x.PositionId)
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("employee_addresses");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Line1).IsRequired().HasMaxLength(256);
+ b.Property(x => x.City).IsRequired().HasMaxLength(128);
+ b.Property(x => x.PostalCode).IsRequired().HasMaxLength(32);
+ b.Property(x => x.Country).IsRequired().HasMaxLength(64);
+
+ b.HasOne(x => x.UserProfile)
+ .WithMany(p => p.Addresses)
+ .HasForeignKey(x => x.UserProfileId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("emergency_contacts");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.Name).IsRequired().HasMaxLength(128);
+ b.Property(x => x.Relationship).IsRequired().HasMaxLength(64);
+
+ b.HasOne(x => x.UserProfile)
+ .WithMany(p => p.EmergencyContacts)
+ .HasForeignKey(x => x.UserProfileId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
+ });
+
+ model.Entity(b =>
+ {
+ b.ToTable("employee_bank_accounts");
+ b.HasKey(x => x.Id);
+
+ b.Property(x => x.BankName).IsRequired().HasMaxLength(128);
+ b.Property(x => x.AccountNumber).IsRequired().HasMaxLength(64);
+ b.Property(x => x.AccountHolder).IsRequired().HasMaxLength(128);
+
+ b.HasOne(x => x.UserProfile)
+ .WithMany(p => p.BankAccounts)
+ .HasForeignKey(x => x.UserProfileId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
+ });
+
+ // ====== Enums as ints ======
+ model.Entity().Property(x => x.Type).HasConversion();
+ model.Entity().Property(x => x.Type).HasConversion();
+ model.Entity().Property(x => x.Provider).HasConversion();
+ model.Entity().Property(x => x.EmploymentType).HasConversion();
+ model.Entity().Property(x => x.Gender).HasConversion();
+
+ // ====== BaseEntity common mapping ======
+ foreach (var et in model.Model.GetEntityTypes()
+ .Where(t => typeof(BaseEntity).IsAssignableFrom(t.ClrType)))
+ {
+ var b = model.Entity(et.ClrType);
+
+ // Tenant
+ b.Property(nameof(BaseEntity.TenantId))
+ .HasColumnName("tenant_id")
+ .HasColumnType("uuid")
+ .IsRequired()
+ .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid");
+
+ b.HasIndex(nameof(BaseEntity.TenantId));
+ b.HasCheckConstraint($"ck_{et.GetTableName()}_tenant_not_null", "tenant_id is not null");
+
+ // Audit
+ b.Property("CreatedAt")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now() at time zone 'utc'");
+ b.Property("UpdatedAt").HasColumnName("updated_at");
+ b.Property("CreatedBy").HasColumnName("created_by");
+ b.Property("UpdatedBy").HasColumnName("updated_by");
+ b.Property("IsDeleted").HasColumnName("is_deleted").HasDefaultValue(false);
+ }
+ }
+}
\ No newline at end of file
diff --git a/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs b/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..4bde9a2
--- /dev/null
+++ b/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs
@@ -0,0 +1,137 @@
+using AMREZ.EOP.Abstractions.Applications.Tenancy;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
+using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
+using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
+using AMREZ.EOP.Abstractions.Infrastructures.Common;
+using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
+using AMREZ.EOP.Abstractions.Security;
+using AMREZ.EOP.Abstractions.Storage;
+using AMREZ.EOP.Application.UseCases.Authentications;
+using AMREZ.EOP.Application.UseCases.HumanResources;
+using AMREZ.EOP.Application.UseCases.Tenancy;
+using AMREZ.EOP.Domain.Shared.Tenancy;
+using AMREZ.EOP.Infrastructures.Data;
+using AMREZ.EOP.Infrastructures.Options;
+using AMREZ.EOP.Infrastructures.Repositories;
+using AMREZ.EOP.Infrastructures.Security;
+using AMREZ.EOP.Infrastructures.Storage;
+using AMREZ.EOP.Infrastructures.Tenancy;
+using AMREZ.EOP.Infrastructures.UnitOfWork;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using StackExchange.Redis;
+
+namespace AMREZ.EOP.Infrastructures.DependencyInjections;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddInfrastructure(this IServiceCollection services)
+ {
+ services.AddHttpContextAccessor();
+
+ // Options
+ services.AddOptions()
+ .BindConfiguration("Connections")
+ .ValidateOnStart();
+
+ // Tenancy core (order matters for AddDbContext factory resolution)
+ services.AddSingleton(sp =>
+ {
+ var map = new TenantMap();
+ var opts = sp.GetRequiredService>().Value;
+ map.UpsertTenant("public", new TenantContext
+ {
+ Id = "public",
+ Schema = "public",
+ ConnectionString = string.IsNullOrWhiteSpace(opts.DefaultConnection) ? null : opts.DefaultConnection,
+ Mode = TenantMode.Rls
+ });
+ return map;
+ });
+ services.AddHostedService();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // DbContext (attach RLS/search_path interceptors)
+ services.AddDbContext((sp, o) =>
+ {
+ var cfg = sp.GetRequiredService>().Value;
+ o.UseNpgsql(cfg.DefaultConnection);
+ o.AddInterceptors(
+ sp.GetRequiredService(),
+ sp.GetRequiredService()
+ );
+ });
+
+ // Db scope / UoW
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // Security
+ services.AddScoped();
+
+ // Repositories — Auth & HR แยกกัน
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ // UseCases — Authentication
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped