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

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
**/bin
**/obj
**/TestResults
**/*.user
**/*.swp
**/*.suo
*.md
*.ps1
*.sh

62
.gitignore vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.6"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AMREZ.EOP.Abstractions\AMREZ.EOP.Abstractions.csproj" />
<ProjectReference Include="..\AMREZ.EOP.Domain\AMREZ.EOP.Domain.csproj" />
<ProjectReference Include="..\AMREZ.EOP.Infrastructures\AMREZ.EOP.Infrastructures.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@AMREZ.EOP.API_HostAddress = http://localhost:5063
GET {{AMREZ.EOP.API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -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<IActionResult> 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<Claim>
{
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<IActionResult> 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<IActionResult> 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<IActionResult> Logout()
{
await HttpContext.SignOutAsync(AuthPolicies.Scheme);
return NoContent();
}
[HttpPost("email")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> RevokeAll([FromBody] LogoutAllRequest body, CancellationToken ct)
{
var n = await _logoutAll.ExecuteAsync(body, ct);
return Ok(new { revoked = n });
}
}

View File

@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}

View File

@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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();
}
}

View File

@@ -0,0 +1,4 @@
namespace AMREZ.EOP.API.Filters;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public sealed class SkipTenantGuardAttribute : Attribute { }

View File

@@ -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<StrictTenantGuardMiddleware> _log;
private readonly StrictTenantGuardOptions _opts;
private readonly TenantMap _map;
public StrictTenantGuardMiddleware(
ILogger<StrictTenantGuardMiddleware> 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<SkipTenantGuardAttribute>() 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");
}
}

141
AMREZ.EOP.API/Program.cs Normal file
View File

@@ -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<AuthOptions>(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<string>()
}
});
});
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<StrictTenantGuardMiddleware>();
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<StrictTenantGuardMiddleware>();
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<Microsoft.Extensions.Options.IOptions<AuthOptions>>().Value;
var factory = sp.GetRequiredService<ITenantDbContextFactory>();
if (!opts.UseSchemaPerTenant)
{
await using var db = factory.Create<AppDbContext>(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<AppDbContext>(t);
await db.Database.MigrateAsync();
}
}
static async Task<List<string>> GetAppSchemasAsync(string connectionString)
{
var list = new List<string>();
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;
}

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -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": "*"
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AMREZ.EOP.Contracts\AMREZ.EOP.Contracts.csproj" />
<ProjectReference Include="..\AMREZ.EOP.Domain\AMREZ.EOP.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -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; }
}

View File

@@ -0,0 +1,6 @@
namespace AMREZ.EOP.Abstractions.Applications.Tenancy;
public interface ITenantDbContextFactory
{
TContext Create<TContext>(ITenantContext tenant) where TContext : class;
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Http;
namespace AMREZ.EOP.Abstractions.Applications.Tenancy;
public interface ITenantResolver
{
ITenantContext? Resolve(HttpContext http, object? hint = null);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface IAddEmailIdentityUseCase
{
Task<bool> ExecuteAsync(AddEmailIdentityRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface IChangePasswordUseCase
{
Task<bool> ExecuteAsync(ChangePasswordRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface IDisableMfaUseCase
{
Task<bool> ExecuteAsync(DisableMfaRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface IEnableTotpUseCase
{
Task<EnableTotpResponse?> ExecuteAsync(EnableTotpRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface ILoginUseCase
{
Task<LoginResponse?> ExecuteAsync(LoginRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface ILogoutAllUseCase
{
Task<int> ExecuteAsync(LogoutAllRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.Logout;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface ILogoutUseCase
{
Task<bool> ExecuteAsync(LogoutRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.Register;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface IRegisterUseCase
{
Task<RegisterResponse?> ExecuteAsync(RegisterRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
public interface IVerifyEmailUseCase
{
Task<bool> ExecuteAsync(VerifyEmailRequest request, CancellationToken ct = default);
}

View File

@@ -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<EmergencyContactResponse?> ExecuteAsync(EmergencyContactAddRequest request, CancellationToken ct = default);
}

View File

@@ -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<EmployeeAddressResponse?> ExecuteAsync(EmployeeAddressAddRequest request, CancellationToken ct = default);
}

View File

@@ -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<EmployeeBankAccountResponse?> ExecuteAsync(EmployeeBankAccountAddRequest request, CancellationToken ct = default);
}

View File

@@ -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<EmploymentResponse?> ExecuteAsync(EmploymentAddRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
public interface IEndEmploymentUseCase
{
Task<bool> ExecuteAsync(EmploymentEndRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
public interface ISetPrimaryAddressUseCase
{
Task<bool> ExecuteAsync(SetPrimaryAddressRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
public interface ISetPrimaryBankAccountUseCase
{
Task<bool> ExecuteAsync(SetPrimaryBankAccountRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
public interface ISetPrimaryEmergencyContactUseCase
{
Task<bool> ExecuteAsync(SetPrimaryEmergencyContactRequest request, CancellationToken ct = default);
}

View File

@@ -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<UserProfileResponse?> ExecuteAsync(UserProfileUpsertRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface IAddBaseDomainUseCase
{
Task<AddBaseDomainResponse?> ExecuteAsync(AddBaseDomainRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface ICreateTenantUseCase
{
Task<CreateTenantResponse?> ExecuteAsync(CreateTenantRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,6 @@
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface IDeleteTenantUseCase
{
Task<bool> ExecuteAsync(string tenantKey, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface IListDomainsUseCase
{
Task<ListDomainsResponse?> ExecuteAsync(ListDomainsRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface IListTenantsUseCase
{
Task<ListTenantsResponse?> ExecuteAsync(ListTenantsRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface IMapDomainUseCase
{
Task<MapDomainResponse?> ExecuteAsync(MapDomainRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface IRemoveBaseDomainUseCase
{
Task<RemoveBaseDomainResponse?> ExecuteAsync(RemoveBaseDomainRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,6 @@
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface ITenantProvisioner
{
Task ProvisionAsync(string tenantKey, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface IUnmapDomainUseCase
{
Task<UnmapDomainResponse?> ExecuteAsync(UnmapDomainRequest request, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant;
namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy;
public interface IUpdateTenantUseCase
{
Task<UpdateTenantResponse?> ExecuteAsync(UpdateTenantRequest request, CancellationToken ct = default);
}

View File

@@ -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"
}

View File

@@ -0,0 +1,21 @@
using AMREZ.EOP.Domain.Entities.Tenancy;
namespace AMREZ.EOP.Abstractions.Infrastructures.Repositories;
public interface ITenantRepository
{
Task<bool> TenantExistsAsync(string tenantKey, CancellationToken ct = default);
Task<TenantConfig?> GetAsync(string tenantKey, CancellationToken ct = default);
Task<IReadOnlyList<TenantConfig>> ListTenantsAsync(CancellationToken ct = default);
Task<bool> CreateAsync(TenantConfig row, CancellationToken ct = default);
Task<bool> UpdateAsync(TenantConfig row, DateTimeOffset? ifUnmodifiedSince, CancellationToken ct = default);
Task<bool> DeleteAsync(string tenantKey, CancellationToken ct = default);
Task<bool> MapDomainAsync(string domain, string tenantKey, CancellationToken ct = default);
Task<bool> UnmapDomainAsync(string domain, CancellationToken ct = default);
Task<IReadOnlyList<TenantDomain>> ListDomainsAsync(string? tenantKey, CancellationToken ct = default);
Task<bool> AddBaseDomainAsync(string baseDomain, CancellationToken ct = default);
Task<bool> RemoveBaseDomainAsync(string baseDomain, CancellationToken ct = default);
}

View File

@@ -0,0 +1,21 @@
using AMREZ.EOP.Domain.Entities.HumanResources;
namespace AMREZ.EOP.Abstractions.Infrastructures.Repositories;
public interface IUserProfileRepository
{
Task<UserProfile?> GetByUserIdAsync(Guid userId, CancellationToken ct = default);
Task UpsertAsync(UserProfile profile, CancellationToken ct = default);
Task<Employment> AddEmploymentAsync(Employment e, CancellationToken ct = default);
Task EndEmploymentAsync(Guid employmentId, DateTime endDate, CancellationToken ct = default);
Task<EmployeeAddress> AddAddressAsync(EmployeeAddress a, CancellationToken ct = default);
Task SetPrimaryAddressAsync(Guid userProfileId, Guid addressId, CancellationToken ct = default);
Task<EmergencyContact> AddEmergencyContactAsync(EmergencyContact c, CancellationToken ct = default);
Task SetPrimaryEmergencyContactAsync(Guid userProfileId, Guid contactId, CancellationToken ct = default);
Task<EmployeeBankAccount> AddBankAccountAsync(EmployeeBankAccount b, CancellationToken ct = default);
Task SetPrimaryBankAccountAsync(Guid userProfileId, Guid bankAccountId, CancellationToken ct = default);
}

View File

@@ -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<User?> FindByIdAsync(Guid userId, CancellationToken ct = default);
Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default);
Task<bool> 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<UserIdentity?> 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<UserMfaFactor> AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default);
Task DisableMfaFactorAsync(Guid factorId, CancellationToken ct = default);
Task<bool> HasAnyMfaAsync(Guid userId, CancellationToken ct = default);
// Sessions
Task<UserSession> CreateSessionAsync(UserSession session, CancellationToken ct = default);
Task<int> RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default);
Task<int> RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,7 @@
namespace AMREZ.EOP.Abstractions.Security;
public interface IPasswordHasher
{
bool Verify(string plain, string hash);
string Hash(string plain);
}

View File

@@ -0,0 +1,9 @@
using AMREZ.EOP.Abstractions.Applications.Tenancy;
namespace AMREZ.EOP.Abstractions.Storage;
public interface IDbScope
{
void EnsureForTenant(ITenantContext tenant);
TContext Get<TContext>() where TContext : class;
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AMREZ.EOP.Abstractions\AMREZ.EOP.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
</ItemGroup>
</Project>

View File

@@ -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<bool> 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;
}
}
}

View File

@@ -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<bool> 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; }
}
}

View File

@@ -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<bool> 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; }
}
}

View File

@@ -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<EnableTotpResponse?> 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; }
}
}

View File

@@ -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<LoginResponse?> 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;
}
}
}

View File

@@ -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<int> 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;
}
}
}

View File

@@ -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<bool> 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; }
}
}

View File

@@ -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<RegisterResponse?> 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;
}
}
}

View File

@@ -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<bool> 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; }
}
}

View File

@@ -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<EmergencyContactResponse?> 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; }
}
}

View File

@@ -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<EmployeeAddressResponse?> 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;
}
}
}

View File

@@ -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<EmployeeBankAccountResponse?> 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;
}
}
}

View File

@@ -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<EmploymentResponse?> 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;
}
}
}

View File

@@ -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<bool> 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;
}
}
}

View File

@@ -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<bool> 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;
}
}
}

View File

@@ -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<bool> 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;
}
}
}

View File

@@ -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<bool> 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;
}
}
}

View File

@@ -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<UserProfileResponse?> 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;
}
}
}

View File

@@ -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<AddBaseDomainResponse?> 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;
}
}
}

View File

@@ -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<CreateTenantResponse?> 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;
}
}
}

View File

@@ -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<bool> 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;
}
}
}

View File

@@ -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<ListDomainsResponse?> 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;
}
}
}

View File

@@ -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<ListTenantsResponse?> 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;
}
}
}

View File

@@ -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<MapDomainResponse?> 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;
}
}
}

View File

@@ -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<RemoveBaseDomainResponse?> 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;
}
}
}

View File

@@ -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<UnmapDomainResponse?> 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;
}
}
}

View File

@@ -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<UpdateTenantResponse?> 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;
}
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AMREZ.EOP.Domain\AMREZ.EOP.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -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;
}

View File

@@ -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!;
}

View File

@@ -0,0 +1,6 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa;
public sealed class DisableMfaRequest
{
public Guid FactorId { get; set; }
}

View File

@@ -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!;
}

View File

@@ -0,0 +1,6 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
public sealed record EnableTotpResponse(
Guid FactorId,
string Label
);

View File

@@ -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!;
}

View File

@@ -0,0 +1,8 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.Login;
public sealed record LoginResponse(
Guid UserId,
string DisplayName,
string Email,
string TenantId
);

View File

@@ -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; }
}

View File

@@ -0,0 +1,6 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll;
public sealed class LogoutAllRequest
{
public Guid UserId { get; set; }
}

View File

@@ -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!;
}

View File

@@ -0,0 +1,3 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.Register;
public sealed record RegisterResponse(Guid UserId, string Name, string Email, string Tenant);

View File

@@ -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!;
}

View File

@@ -0,0 +1,7 @@
namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContact;
public sealed record EmergencyContactResponse(
Guid Id,
Guid UserProfileId,
bool IsPrimary
);

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddress;
public sealed record EmployeeAddressResponse(
Guid Id,
Guid UserProfileId,
bool IsPrimary
);

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccount;
public sealed record EmployeeBankAccountResponse(
Guid Id,
Guid UserProfileId,
bool IsPrimary
);

View File

@@ -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;
}

View File

@@ -0,0 +1,8 @@
namespace AMREZ.EOP.Contracts.DTOs.HumanResources.Employment;
public sealed record EmploymentResponse(
Guid Id,
Guid UserProfileId,
DateTime StartDate,
DateTime? EndDate
);

View File

@@ -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; }
}

Some files were not shown because too many files have changed in this diff Show More