commit 92e614674cf775f99453b33d660dbd3347292712 Author: Thanakarn Klangkasame <77600906+Simulationable@users.noreply.github.com> Date: Tue Sep 30 11:01:02 2025 +0700 Init Git diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a9e2c35 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +**/bin +**/obj +**/TestResults +**/*.user +**/*.swp +**/*.suo +*.md +*.ps1 +*.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ff7e6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio Code +.vscode/ + +# Rider +.idea/ +*.sln.iml + +# NuGet +*.nupkg +*.snupkg +.nuget/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# Dotnet watch +**/node_modules/ +**/dist/ +**/tmp/ +**/.tmp/ + +# Others +*.dbmdl +*.bak +*.sql +*.pdb +*.cache +*.config +*.log +*.vspscc +*.vssscc +*.vs/ + +# Docker +docker-compose.override.yml diff --git a/AMREZ.EOP.API/AMREZ.EOP.API.csproj b/AMREZ.EOP.API/AMREZ.EOP.API.csproj new file mode 100644 index 0000000..be74a5e --- /dev/null +++ b/AMREZ.EOP.API/AMREZ.EOP.API.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/AMREZ.EOP.API/AMREZ.EOP.API.http b/AMREZ.EOP.API/AMREZ.EOP.API.http new file mode 100644 index 0000000..000cc58 --- /dev/null +++ b/AMREZ.EOP.API/AMREZ.EOP.API.http @@ -0,0 +1,6 @@ +@AMREZ.EOP.API_HostAddress = http://localhost:5063 + +GET {{AMREZ.EOP.API_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/AMREZ.EOP.API/Controllers/AuthenticationController.cs b/AMREZ.EOP.API/Controllers/AuthenticationController.cs new file mode 100644 index 0000000..6ea7461 --- /dev/null +++ b/AMREZ.EOP.API/Controllers/AuthenticationController.cs @@ -0,0 +1,136 @@ +using System.Security.Claims; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity; +using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword; +using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa; +using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp; +using AMREZ.EOP.Contracts.DTOs.Authentications.Login; +using AMREZ.EOP.Contracts.DTOs.Authentications.Logout; +using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll; +using AMREZ.EOP.Contracts.DTOs.Authentications.Register; +using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail; +using AMREZ.EOP.Domain.Shared.Contracts; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; + +namespace AMREZ.EOP.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthenticationController : ControllerBase +{ + private readonly ILoginUseCase _login; + private readonly IRegisterUseCase _register; + private readonly IChangePasswordUseCase _changePassword; + + private readonly IAddEmailIdentityUseCase _addEmail; + private readonly IVerifyEmailUseCase _verifyEmail; + + private readonly IEnableTotpUseCase _enableTotp; + private readonly IDisableMfaUseCase _disableMfa; + + private readonly ILogoutUseCase _logout; + private readonly ILogoutAllUseCase _logoutAll; + + public AuthenticationController( + ILoginUseCase login, + IRegisterUseCase register, + IChangePasswordUseCase changePassword) + { + _login = login; + _register = register; + _changePassword = changePassword; + } + + [HttpPost("login")] + public async Task PostLogin([FromBody] LoginRequest body, CancellationToken ct) + { + var res = await _login.ExecuteAsync(body, ct); + if (res is null) return Unauthorized(new { message = "Invalid credentials" }); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, res.UserId.ToString()), + new(ClaimTypes.Name, string.IsNullOrWhiteSpace(res.DisplayName) ? res.Email : res.DisplayName), + new(ClaimTypes.Email, res.Email), + new("tenant", res.TenantId) + }; + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthPolicies.Scheme)); + await HttpContext.SignInAsync(AuthPolicies.Scheme, principal); + + return Ok(res); + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest body, CancellationToken ct) + { + var res = await _register.ExecuteAsync(body, ct); + if (res is null) + return Conflict(new { message = "Email already exists or tenant not found" }); + + return Created($"/api/authentication/users/{res.UserId}", res); + } + + [HttpPost("change-password")] + public async Task ChangePassword([FromBody] ChangePasswordRequest body, CancellationToken ct) + { + var ok = await _changePassword.ExecuteAsync(body, ct); + if (!ok) return BadRequest(new { message = "Change password failed" }); + return NoContent(); + } + + [HttpPost("logout")] + public async Task Logout() + { + await HttpContext.SignOutAsync(AuthPolicies.Scheme); + return NoContent(); + } + + [HttpPost("email")] + public async Task AddEmail([FromBody] AddEmailIdentityRequest body, CancellationToken ct) + { + var ok = await _addEmail.ExecuteAsync(body, ct); + if (!ok) return BadRequest(new { message = "Cannot add email identity" }); + return NoContent(); + } + + [HttpPost("email/verify")] + public async Task VerifyEmail([FromBody] VerifyEmailRequest body, CancellationToken ct) + { + var ok = await _verifyEmail.ExecuteAsync(body, ct); + if (!ok) return BadRequest(new { message = "Verify email failed" }); + return NoContent(); + } + + [HttpPost("totp/enable")] + public async Task EnableTotp([FromBody] EnableTotpRequest body, CancellationToken ct) + { + var res = await _enableTotp.ExecuteAsync(body, ct); + if (res is null) return BadRequest(new { message = "Enable TOTP failed" }); + return Ok(res); + } + + [HttpPost("disable")] + public async Task DisableMfa([FromBody] DisableMfaRequest body, CancellationToken ct) + { + var ok = await _disableMfa.ExecuteAsync(body, ct); + if (!ok) return BadRequest(new { message = "Disable MFA failed" }); + return NoContent(); + } + + [HttpPost("revoke")] + public async Task Revoke([FromBody] LogoutRequest body, CancellationToken ct) + { + var ok = await _logout.ExecuteAsync(body, ct); + if (!ok) return NotFound(new { message = "Session not found" }); + return NoContent(); + } + + [HttpPost("revoke-all")] + public async Task RevokeAll([FromBody] LogoutAllRequest body, CancellationToken ct) + { + var n = await _logoutAll.ExecuteAsync(body, ct); + return Ok(new { revoked = n }); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.API/Controllers/HumanResourcesController.cs b/AMREZ.EOP.API/Controllers/HumanResourcesController.cs new file mode 100644 index 0000000..074e5d3 --- /dev/null +++ b/AMREZ.EOP.API/Controllers/HumanResourcesController.cs @@ -0,0 +1,128 @@ +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd; +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress; +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount; +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact; +using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert; +using Microsoft.AspNetCore.Mvc; + +namespace AMREZ.EOP.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class HumanResourcesController : ControllerBase +{ + private readonly IUpsertUserProfileUseCase _upsertProfile; + private readonly IAddEmploymentUseCase _addEmployment; + private readonly IEndEmploymentUseCase _endEmployment; + private readonly IAddEmployeeAddressUseCase _addAddress; + private readonly ISetPrimaryAddressUseCase _setPrimaryAddress; + private readonly IAddEmergencyContactUseCase _addEmergencyContact; + private readonly ISetPrimaryEmergencyContactUseCase _setPrimaryEmergencyContact; + private readonly IAddEmployeeBankAccountUseCase _addBankAccount; + private readonly ISetPrimaryBankAccountUseCase _setPrimaryBankAccount; + + public HumanResourcesController( + IUpsertUserProfileUseCase upsertProfile, + IAddEmploymentUseCase addEmployment, + IEndEmploymentUseCase endEmployment, + IAddEmployeeAddressUseCase addAddress, + ISetPrimaryAddressUseCase setPrimaryAddress, + IAddEmergencyContactUseCase addEmergencyContact, + ISetPrimaryEmergencyContactUseCase setPrimaryEmergencyContact, + IAddEmployeeBankAccountUseCase addBankAccount, + ISetPrimaryBankAccountUseCase setPrimaryBankAccount) + { + _upsertProfile = upsertProfile; + _addEmployment = addEmployment; + _endEmployment = endEmployment; + _addAddress = addAddress; + _setPrimaryAddress = setPrimaryAddress; + _addEmergencyContact = addEmergencyContact; + _setPrimaryEmergencyContact = setPrimaryEmergencyContact; + _addBankAccount = addBankAccount; + _setPrimaryBankAccount = setPrimaryBankAccount; + } + + // ===== User Profile ===== + [HttpPost("profiles/upsert")] + public async Task UpsertProfile([FromBody] UserProfileUpsertRequest body, CancellationToken ct) + { + var res = await _upsertProfile.ExecuteAsync(body, ct); + if (res is null) return BadRequest(new { message = "Upsert failed" }); + return Ok(res); + } + + // ===== Employment ===== + [HttpPost("employments")] + public async Task AddEmployment([FromBody] EmploymentAddRequest body, CancellationToken ct) + { + var res = await _addEmployment.ExecuteAsync(body, ct); + if (res is null) return BadRequest(new { message = "Add employment failed" }); + return Created($"/api/humanresources/employments/{res.Id}", res); + } + + [HttpPost("employments/{id:guid}/end")] + public async Task EndEmployment([FromRoute] Guid id, [FromBody] EmploymentEndRequest body, CancellationToken ct) + { + if (id != body.EmploymentId) return BadRequest(new { message = "Mismatched id" }); + var ok = await _endEmployment.ExecuteAsync(body, ct); + if (!ok) return NotFound(new { message = "Employment not found" }); + return NoContent(); + } + + // ===== Addresses ===== + [HttpPost("addresses")] + public async Task AddAddress([FromBody] EmployeeAddressAddRequest body, CancellationToken ct) + { + var res = await _addAddress.ExecuteAsync(body, ct); + if (res is null) return BadRequest(new { message = "Add address failed" }); + return Created($"/api/humanresources/addresses/{res.Id}", res); + } + + [HttpPost("addresses/set-primary")] + public async Task SetPrimaryAddress([FromBody] SetPrimaryAddressRequest body, CancellationToken ct) + { + var ok = await _setPrimaryAddress.ExecuteAsync(body, ct); + if (!ok) return NotFound(new { message = "Address not found" }); + return NoContent(); + } + + // ===== Emergency Contacts ===== + [HttpPost("emergency-contacts")] + public async Task AddEmergencyContact([FromBody] EmergencyContactAddRequest body, CancellationToken ct) + { + var res = await _addEmergencyContact.ExecuteAsync(body, ct); + if (res is null) return BadRequest(new { message = "Add emergency contact failed" }); + return Created($"/api/humanresources/emergency-contacts/{res.Id}", res); + } + + [HttpPost("emergency-contacts/set-primary")] + public async Task SetPrimaryEmergencyContact([FromBody] SetPrimaryEmergencyContactRequest body, CancellationToken ct) + { + var ok = await _setPrimaryEmergencyContact.ExecuteAsync(body, ct); + if (!ok) return NotFound(new { message = "Emergency contact not found" }); + return NoContent(); + } + + // ===== Bank Accounts ===== + [HttpPost("bank-accounts")] + public async Task AddBankAccount([FromBody] EmployeeBankAccountAddRequest body, CancellationToken ct) + { + var res = await _addBankAccount.ExecuteAsync(body, ct); + if (res is null) return BadRequest(new { message = "Add bank account failed" }); + return Created($"/api/humanresources/bank-accounts/{res.Id}", res); + } + + [HttpPost("bank-accounts/set-primary")] + public async Task SetPrimaryBankAccount([FromBody] SetPrimaryBankAccountRequest body, CancellationToken ct) + { + var ok = await _setPrimaryBankAccount.ExecuteAsync(body, ct); + if (!ok) return NotFound(new { message = "Bank account not found" }); + return NoContent(); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.API/Controllers/TenancyController.cs b/AMREZ.EOP.API/Controllers/TenancyController.cs new file mode 100644 index 0000000..a7c09a3 --- /dev/null +++ b/AMREZ.EOP.API/Controllers/TenancyController.cs @@ -0,0 +1,192 @@ +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain; +using AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant; +using AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains; +using AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants; +using AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain; +using AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain; +using AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain; +using AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant; +using Microsoft.AspNetCore.Mvc; + +namespace AMREZ.EOP.API.Controllers; + +[ApiController] +[Route("api/[controller]")] +public sealed class TenancyController : ControllerBase +{ + private readonly ICreateTenantUseCase _createTenant; + private readonly IUpdateTenantUseCase _updateTenant; + private readonly IDeleteTenantUseCase _deleteTenant; + private readonly IMapDomainUseCase _mapDomain; + private readonly IUnmapDomainUseCase _unmapDomain; + private readonly IAddBaseDomainUseCase _addBaseDomain; + private readonly IRemoveBaseDomainUseCase _removeBaseDomain; + private readonly IListTenantsUseCase _listTenants; + private readonly IListDomainsUseCase _listDomains; + private readonly ITenantResolver _resolver; + + public TenancyController( + ICreateTenantUseCase createTenant, + IUpdateTenantUseCase updateTenant, + IDeleteTenantUseCase deleteTenant, + IMapDomainUseCase mapDomain, + IUnmapDomainUseCase unmapDomain, + IAddBaseDomainUseCase addBaseDomain, + IRemoveBaseDomainUseCase removeBaseDomain, + IListTenantsUseCase listTenants, + IListDomainsUseCase listDomains, + ITenantResolver resolver) + { + _createTenant = createTenant; + _updateTenant = updateTenant; + _deleteTenant = deleteTenant; + _mapDomain = mapDomain; + _unmapDomain = unmapDomain; + _addBaseDomain = addBaseDomain; + _removeBaseDomain = removeBaseDomain; + _listTenants = listTenants; + _listDomains = listDomains; + _resolver = resolver; + } + + private static object ErrTenantResolve => new + { + code = "TENANT_CONTEXT_NOT_RESOLVED", + message = "Check X-Tenant (e.g., public) or permission scope." + }; + + // Tenants + // Create: อย่า feed body เข้า resolver (tenant ยังไม่เกิด) ให้ใช้ context ปัจจุบันเท่านั้น + [HttpPost("tenants")] + public async Task CreateTenant([FromBody] CreateTenantRequest body, CancellationToken ct) + { + var resolved = _resolver.Resolve(HttpContext, hint: null); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var res = await _createTenant.ExecuteAsync(body, ct); + if (res is null) + return Conflict(new { code = "TENANT_EXISTS", message = "Use PUT to update/reactivate." }); + + return Created($"/api/Tenancy/tenants/{res.TenantKey}", res); + } + + [HttpPut("tenants/{tenantKey}")] + public async Task UpdateTenant([FromRoute] string tenantKey, [FromBody] UpdateTenantRequest body, CancellationToken ct) + { + if (!string.Equals(tenantKey?.Trim(), body.TenantKey?.Trim(), StringComparison.OrdinalIgnoreCase)) + return BadRequest(new { code = "TENANT_KEY_MISMATCH", message = "Route tenantKey must match body.TenantKey" }); + + // การแก้ไข tenant ที่มีอยู่: ให้ resolver ใช้ tenantKey เป็น hint เพื่อบังคับ context ให้ถูกต้อง + var resolved = _resolver.Resolve(HttpContext, hint: null); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var res = await _updateTenant.ExecuteAsync(body, ct); + if (res is null) + return NotFound(new { code = "TENANT_UPDATE_FAILED", message = "Update failed (not found, precondition, or tenant not resolved)" }); + + return Ok(res); + } + + [HttpDelete("tenants/{tenantKey}")] + public async Task DeleteTenant([FromRoute] string tenantKey, CancellationToken ct) + { + var resolved = _resolver.Resolve(HttpContext, hint: null); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var ok = await _deleteTenant.ExecuteAsync(tenantKey, ct); + if (!ok) + return NotFound(new { code = "TENANT_DELETE_FAILED", message = "Delete failed (not found or tenant not resolved)" }); + + return NoContent(); + } + + [HttpGet("tenants")] + public async Task ListTenants(CancellationToken ct) + { + var resolved = _resolver.Resolve(HttpContext, hint: null); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var res = await _listTenants.ExecuteAsync(new ListTenantsRequest(), ct); + if (res is null) + return NotFound(new { code = "TENANT_LIST_FAILED", message = "Tenant resolve failed" }); + + return Ok(res); + } + + // Domains + [HttpGet("domains")] + public async Task ListDomains([FromQuery] string? tenantKey, CancellationToken ct) + { + var resolved = _resolver.Resolve(HttpContext, hint: null); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var res = await _listDomains.ExecuteAsync(new ListDomainsRequest { TenantKey = tenantKey }, ct); + if (res is null) + return NotFound(new { code = "DOMAIN_LIST_FAILED", message = "Tenant resolve failed" }); + + return Ok(res); + } + + [HttpPost("domains/map")] + public async Task MapDomain([FromBody] MapDomainRequest body, CancellationToken ct) + { + var resolved = _resolver.Resolve(HttpContext, hint: body?.TenantKey); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var res = await _mapDomain.ExecuteAsync(body, ct); + if (res is null) + return Conflict(new { code = "DOMAIN_MAP_FAILED", message = "Map failed (tenant/domain invalid or tenant not resolved)" }); + + return Ok(res); + } + + [HttpDelete("domains/{domain}")] + public async Task UnmapDomain([FromRoute] string domain, [FromQuery] string? tenantKey, CancellationToken ct) + { + var resolved = _resolver.Resolve(HttpContext, hint: null); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var res = await _unmapDomain.ExecuteAsync(new UnmapDomainRequest { Domain = domain, TenantKey = tenantKey }, ct); + if (res is null) + return NotFound(new { code = "DOMAIN_UNMAP_FAILED", message = "Unmap failed (not found or tenant not resolved)" }); + + return NoContent(); + } + + [HttpPost("domains/base")] + public async Task AddBaseDomain([FromBody] AddBaseDomainRequest body, CancellationToken ct) + { + var resolved = _resolver.Resolve(HttpContext, hint: null); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var res = await _addBaseDomain.ExecuteAsync(body, ct); + if (res is null) + return Conflict(new { code = "BASE_DOMAIN_ADD_FAILED", message = "Add base domain failed (tenant not resolved or duplicate)" }); + + return Ok(res); + } + + [HttpDelete("domains/base/{baseDomain}")] + public async Task RemoveBaseDomain([FromRoute] string baseDomain, [FromQuery] string? tenantKey, CancellationToken ct) + { + var resolved = _resolver.Resolve(HttpContext, hint: null); + if (resolved is null) + return NotFound(ErrTenantResolve); + + var res = await _removeBaseDomain.ExecuteAsync(new RemoveBaseDomainRequest { BaseDomain = baseDomain, TenantKey = tenantKey }, ct); + if (res is null) + return NotFound(new { code = "BASE_DOMAIN_REMOVE_FAILED", message = "Remove base domain failed (not found or tenant not resolved)" }); + + return NoContent(); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.API/Filters/SkipTenantGuardAttribute.cs b/AMREZ.EOP.API/Filters/SkipTenantGuardAttribute.cs new file mode 100644 index 0000000..fc3231f --- /dev/null +++ b/AMREZ.EOP.API/Filters/SkipTenantGuardAttribute.cs @@ -0,0 +1,4 @@ +namespace AMREZ.EOP.API.Filters; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public sealed class SkipTenantGuardAttribute : Attribute { } \ No newline at end of file diff --git a/AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs b/AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs new file mode 100644 index 0000000..97a2a68 --- /dev/null +++ b/AMREZ.EOP.API/Middleware/StrictTenantGuardMiddleware.cs @@ -0,0 +1,114 @@ +using System.Security.Claims; +using System.Text.RegularExpressions; +using AMREZ.EOP.API.Filters; +using AMREZ.EOP.Infrastructures.Tenancy; + +namespace AMREZ.EOP.API.Middleware; + +public sealed class StrictTenantGuardOptions +{ + public string PlatformSlug { get; set; } = "public"; + public string HeaderName { get; set; } = "X-Tenant"; + public string ClaimName { get; set; } = "tenant"; // ปรับเป็น tid/org ได้ตามระบบ + public HashSet<(string method, string pathPrefix)> Allowlist { get; } = new() + { + ("GET", "/health"), + ("GET", "/swagger"), + ("GET", "/swagger/index.html"), + ("GET", "/swagger/"), + }; + public Regex SlugRegex { get; set; } = new(@"^[a-z0-9\-]{1,64}$", RegexOptions.Compiled); +} + +public sealed class StrictTenantGuardMiddleware : IMiddleware +{ + private readonly ILogger _log; + private readonly StrictTenantGuardOptions _opts; + private readonly TenantMap _map; + + public StrictTenantGuardMiddleware( + ILogger log, + StrictTenantGuardOptions opts, + TenantMap map) + { _log = log; _opts = opts; _map = map; } + + public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) + { + // 1) allowlist แบบเร็ว + var path = ctx.Request.Path.Value ?? string.Empty; + if (_opts.Allowlist.Any(p => + ctx.Request.Method.Equals(p.method, StringComparison.OrdinalIgnoreCase) && + path.StartsWith(p.pathPrefix, StringComparison.OrdinalIgnoreCase))) + { + await next(ctx); + return; + } + + var endpoint = ctx.GetEndpoint(); + if (endpoint?.Metadata?.GetMetadata() is not null) + { + await next(ctx); + return; + } + + // 3) ดึง header + var raw = ctx.Request.Headers[_opts.HeaderName].ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(raw)) + { + ctx.Response.StatusCode = StatusCodes.Status400BadRequest; + await ctx.Response.WriteAsJsonAsync(new { code = "MISSING_TENANT", message = $"{_opts.HeaderName} required" }); + return; + } + + var slug = raw.ToLowerInvariant(); + if (!_opts.SlugRegex.IsMatch(slug)) + { + ctx.Response.StatusCode = StatusCodes.Status400BadRequest; + await ctx.Response.WriteAsJsonAsync(new { code = "INVALID_TENANT_FORMAT", message = "tenant slug invalid" }); + return; + } + + // 4) ตรวจ claim ให้ตรงกับ header + // var claim = GetTenantClaim(ctx.User, _opts.ClaimName); + // if (claim is null || !string.Equals(claim, slug, StringComparison.Ordinal)) + // { + // ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + // await ctx.Response.WriteAsJsonAsync(new { code = "TENANT_MISMATCH", message = "token tenant != header" }); + // return; + // } + + // 5) ตรวจว่ามีอยู่จริงในระบบ (TenantMap/meta) + if (!_map.TryGetBySlug(slug, out var ctxTenant)) + { + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + await ctx.Response.WriteAsJsonAsync(new { code = "TENANT_NOT_FOUND", message = "unknown tenant" }); + return; + } + + // 6) สำหรับ /api/Tenancy/** → ใช้ platform DB แต่ยังต้องรู้ target tenant + var isControlPlane = path.StartsWith("/api/Tenancy", StringComparison.OrdinalIgnoreCase); + if (isControlPlane) + { + // เก็บ target tenant ให้ use case ใช้ + ctx.Items["TargetTenantKey"] = slug; + + // บังคับให้ resolver ใช้ platform (ภายหลัง) + ctx.Items["ForcePlatformContext"] = true; + } + else + { + // business-plane: ใช้ tenant context ปกติ (resolver จะอ่าน X-Tenant ตรง ๆ) + } + + await next(ctx); + } + + private static string? GetTenantClaim(ClaimsPrincipal user, string name) + { + if (user?.Identity?.IsAuthenticated != true) return null; + return user.FindFirstValue(name) + ?? user.FindFirstValue("tid") + ?? user.FindFirstValue("org") + ?? user.FindFirstValue("org_id"); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.API/Program.cs b/AMREZ.EOP.API/Program.cs new file mode 100644 index 0000000..32e8c30 --- /dev/null +++ b/AMREZ.EOP.API/Program.cs @@ -0,0 +1,141 @@ +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.API.Middleware; +using AMREZ.EOP.Domain.Shared.Contracts; +using AMREZ.EOP.Domain.Shared.Tenancy; +using AMREZ.EOP.Infrastructures.Data; +using AMREZ.EOP.Infrastructures.DependencyInjections; +using AMREZ.EOP.Infrastructures.Options; +using AMREZ.EOP.Infrastructures.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Npgsql; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection("Connections")); + +builder.Services.AddInfrastructure(); + +builder.Services.AddOpenApi(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "AMREZ.EOP API", + Version = "v1" + }); + + // Cookie auth (ชื่อคุกกี้ eop.auth) + c.AddSecurityDefinition("cookieAuth", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Cookie, + Name = "eop.auth", + Description = "Sign in via /api/authentication/login to receive cookie." + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme{ + Reference = new OpenApiReference{ Type = ReferenceType.SecurityScheme, Id = "cookieAuth" } + }, + Array.Empty() + } + }); +}); + +builder.Services + .AddAuthentication(AuthPolicies.Scheme) + .AddCookie(AuthPolicies.Scheme, o => { o.LoginPath="/api/authentication/login"; o.Cookie.Name="eop.auth"; o.SlidingExpiration=true; }); + +builder.Services.AddAuthorization(); + +builder.Services.AddSingleton(new StrictTenantGuardOptions +{ + PlatformSlug = "public", + HeaderName = "X-Tenant", + ClaimName = "tenant" +}); +builder.Services.AddTransient(); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + await ApplyMigrationsAsync(app.Services); + + app.MapOpenApi(); + + app.UseSwagger(); + app.UseSwaggerUI(o => + { + o.SwaggerEndpoint("/swagger/v1/swagger.json", "AMREZ.EOP API v1"); + o.RoutePrefix = "swagger"; + o.DisplayRequestDuration(); + }); +} + +app.UseHttpsRedirection(); + +app.UseAuthentication(); + +app.UseMiddleware(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); + +static async Task ApplyMigrationsAsync(IServiceProvider services) +{ + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + + var opts = sp.GetRequiredService>().Value; + var factory = sp.GetRequiredService(); + + if (!opts.UseSchemaPerTenant) + { + await using var db = factory.Create(new TenantContext { Id = "_shared_" }); + await db.Database.MigrateAsync(); + } + + var schemas = await GetAppSchemasAsync(opts.DefaultConnection); + + foreach (var schema in schemas) + { + var t = new TenantContext { Id = schema, Schema = schema, Mode = TenantMode.Schema }; + await using var db = factory.Create(t); + await db.Database.MigrateAsync(); + } +} + +static async Task> GetAppSchemasAsync(string connectionString) +{ + var list = new List(); + + await using var conn = new NpgsqlConnection(connectionString); + await conn.OpenAsync(); + + const string sql = @" + SELECT nspname + FROM pg_namespace + WHERE nspname NOT LIKE 'pg_%' + AND nspname <> 'information_schema' + ORDER BY nspname;"; + + await using var cmd = new NpgsqlCommand(sql, conn); + await using var rd = await cmd.ExecuteReaderAsync(); + while (await rd.ReadAsync()) + list.Add(rd.GetString(0)); + + if (list.Count == 0) list.Add("public"); + + return list; +} \ No newline at end of file diff --git a/AMREZ.EOP.API/Properties/launchSettings.json b/AMREZ.EOP.API/Properties/launchSettings.json new file mode 100644 index 0000000..95557ab --- /dev/null +++ b/AMREZ.EOP.API/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5063", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7275;http://localhost:5063", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/AMREZ.EOP.API/appsettings.Development.json b/AMREZ.EOP.API/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/AMREZ.EOP.API/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/AMREZ.EOP.API/appsettings.json b/AMREZ.EOP.API/appsettings.json new file mode 100644 index 0000000..ba290c6 --- /dev/null +++ b/AMREZ.EOP.API/appsettings.json @@ -0,0 +1,16 @@ +{ + "Connections": { + "DefaultConnection": "Host=127.0.0.1;Port=15432;User ID=amrez;Password=Initial1!;Database=amrez-app-db;Search Path=public;", + "UseSchemaPerTenant": false, + "StorageBackend": "ef", + "RedisConnection": "localhost:6379", + "RedisDb": 0 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj b/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj new file mode 100644 index 0000000..f1b38eb --- /dev/null +++ b/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantContext.cs b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantContext.cs new file mode 100644 index 0000000..53e33cf --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantContext.cs @@ -0,0 +1,9 @@ +namespace AMREZ.EOP.Abstractions.Applications.Tenancy; + +public interface ITenantContext +{ + string TenantKey { get; } + string Id { get; } + string? Schema { get; } + string? ConnectionString { get; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantDbContextFactory.cs b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantDbContextFactory.cs new file mode 100644 index 0000000..0fd0ed7 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantDbContextFactory.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Abstractions.Applications.Tenancy; + +public interface ITenantDbContextFactory +{ + TContext Create(ITenantContext tenant) where TContext : class; +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantResolver.cs b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantResolver.cs new file mode 100644 index 0000000..4ba964d --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/Tenancy/ITenantResolver.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Abstractions.Applications.Tenancy; + +public interface ITenantResolver +{ + ITenantContext? Resolve(HttpContext http, object? hint = null); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IAddEmailIdentityUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IAddEmailIdentityUseCase.cs new file mode 100644 index 0000000..a3f2d2f --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IAddEmailIdentityUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface IAddEmailIdentityUseCase +{ + Task ExecuteAsync(AddEmailIdentityRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IChangePasswordUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IChangePasswordUseCase.cs new file mode 100644 index 0000000..2e5a572 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IChangePasswordUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface IChangePasswordUseCase +{ + Task ExecuteAsync(ChangePasswordRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IDisableMfaUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IDisableMfaUseCase.cs new file mode 100644 index 0000000..ff9a3c8 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IDisableMfaUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface IDisableMfaUseCase +{ + Task ExecuteAsync(DisableMfaRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IEnableTotpUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IEnableTotpUseCase.cs new file mode 100644 index 0000000..1886b3a --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IEnableTotpUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface IEnableTotpUseCase +{ + Task ExecuteAsync(EnableTotpRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILoginUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILoginUseCase.cs new file mode 100644 index 0000000..cc188ce --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILoginUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.Login; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface ILoginUseCase +{ + Task ExecuteAsync(LoginRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutAllUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutAllUseCase.cs new file mode 100644 index 0000000..15c79c0 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutAllUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface ILogoutAllUseCase +{ + Task ExecuteAsync(LogoutAllRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutUseCase.cs new file mode 100644 index 0000000..3f29155 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/ILogoutUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.Logout; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface ILogoutUseCase +{ + Task ExecuteAsync(LogoutRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IRegisterUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IRegisterUseCase.cs new file mode 100644 index 0000000..ffc6982 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IRegisterUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.Register; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface IRegisterUseCase +{ + Task ExecuteAsync(RegisterRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IVerifyEmailUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IVerifyEmailUseCase.cs new file mode 100644 index 0000000..ff5c9f0 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Authentications/IVerifyEmailUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; + +public interface IVerifyEmailUseCase +{ + Task ExecuteAsync(VerifyEmailRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmergencyContactUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmergencyContactUseCase.cs new file mode 100644 index 0000000..063fbba --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmergencyContactUseCase.cs @@ -0,0 +1,9 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContact; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface IAddEmergencyContactUseCase +{ + Task ExecuteAsync(EmergencyContactAddRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeAddressUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeAddressUseCase.cs new file mode 100644 index 0000000..c498ae5 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeAddressUseCase.cs @@ -0,0 +1,9 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddress; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface IAddEmployeeAddressUseCase +{ + Task ExecuteAsync(EmployeeAddressAddRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeBankAccountUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeBankAccountUseCase.cs new file mode 100644 index 0000000..af57a85 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmployeeBankAccountUseCase.cs @@ -0,0 +1,9 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccount; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface IAddEmployeeBankAccountUseCase +{ + Task ExecuteAsync(EmployeeBankAccountAddRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmploymentUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmploymentUseCase.cs new file mode 100644 index 0000000..246f1f9 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IAddEmploymentUseCase.cs @@ -0,0 +1,9 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.Employment; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface IAddEmploymentUseCase +{ + Task ExecuteAsync(EmploymentAddRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IEndEmploymentUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IEndEmploymentUseCase.cs new file mode 100644 index 0000000..a51a124 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IEndEmploymentUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface IEndEmploymentUseCase +{ + Task ExecuteAsync(EmploymentEndRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryAddressUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryAddressUseCase.cs new file mode 100644 index 0000000..35a3dfe --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryAddressUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface ISetPrimaryAddressUseCase +{ + Task ExecuteAsync(SetPrimaryAddressRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryBankAccountUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryBankAccountUseCase.cs new file mode 100644 index 0000000..a5d14e7 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryBankAccountUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface ISetPrimaryBankAccountUseCase +{ + Task ExecuteAsync(SetPrimaryBankAccountRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryEmergencyContactUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryEmergencyContactUseCase.cs new file mode 100644 index 0000000..165891c --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/ISetPrimaryEmergencyContactUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface ISetPrimaryEmergencyContactUseCase +{ + Task ExecuteAsync(SetPrimaryEmergencyContactRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IUpsertUserProfileUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IUpsertUserProfileUseCase.cs new file mode 100644 index 0000000..5f48b48 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/HumanResources/IUpsertUserProfileUseCase.cs @@ -0,0 +1,9 @@ +using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfile; +using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; + +public interface IUpsertUserProfileUseCase +{ + Task ExecuteAsync(UserProfileUpsertRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IAddBaseDomainUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IAddBaseDomainUseCase.cs new file mode 100644 index 0000000..fb50427 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IAddBaseDomainUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface IAddBaseDomainUseCase +{ + Task ExecuteAsync(AddBaseDomainRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ICreateTenantUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ICreateTenantUseCase.cs new file mode 100644 index 0000000..eedda52 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ICreateTenantUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface ICreateTenantUseCase +{ + Task ExecuteAsync(CreateTenantRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IDeleteTenantUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IDeleteTenantUseCase.cs new file mode 100644 index 0000000..8470c8e --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IDeleteTenantUseCase.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface IDeleteTenantUseCase +{ + Task ExecuteAsync(string tenantKey, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListDomainsUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListDomainsUseCase.cs new file mode 100644 index 0000000..9524a1d --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListDomainsUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface IListDomainsUseCase +{ + Task ExecuteAsync(ListDomainsRequest request, CancellationToken ct = default); +} diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListTenantsUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListTenantsUseCase.cs new file mode 100644 index 0000000..2876396 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IListTenantsUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface IListTenantsUseCase +{ + Task ExecuteAsync(ListTenantsRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IMapDomainUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IMapDomainUseCase.cs new file mode 100644 index 0000000..0c8fb82 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IMapDomainUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface IMapDomainUseCase +{ + Task ExecuteAsync(MapDomainRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IRemoveBaseDomainUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IRemoveBaseDomainUseCase.cs new file mode 100644 index 0000000..68d4498 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IRemoveBaseDomainUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface IRemoveBaseDomainUseCase +{ + Task ExecuteAsync(RemoveBaseDomainRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ITenantProvisioner.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ITenantProvisioner.cs new file mode 100644 index 0000000..631f6c2 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/ITenantProvisioner.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface ITenantProvisioner +{ + Task ProvisionAsync(string tenantKey, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUnmapDomainUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUnmapDomainUseCase.cs new file mode 100644 index 0000000..9a88c27 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUnmapDomainUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface IUnmapDomainUseCase +{ + Task ExecuteAsync(UnmapDomainRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUpdateTenantUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUpdateTenantUseCase.cs new file mode 100644 index 0000000..f0e5e8a --- /dev/null +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Tenancy/IUpdateTenantUseCase.cs @@ -0,0 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant; + +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; + +public interface IUpdateTenantUseCase +{ + Task ExecuteAsync(UpdateTenantRequest request, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Common/IUnitOfWork.cs b/AMREZ.EOP.Abstractions/Infrastructures/Common/IUnitOfWork.cs new file mode 100644 index 0000000..6e54904 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Infrastructures/Common/IUnitOfWork.cs @@ -0,0 +1,12 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; + +namespace AMREZ.EOP.Abstractions.Infrastructures.Common; + +public interface IUnitOfWork +{ + Task BeginAsync(ITenantContext tenant, IsolationLevel isolation = IsolationLevel.ReadCommitted, CancellationToken ct = default); + Task CommitAsync(CancellationToken ct = default); + Task RollbackAsync(CancellationToken ct = default); + string Backend { get; } // "ef" | "redis" +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Repositories/ITenantRepository.cs b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/ITenantRepository.cs new file mode 100644 index 0000000..59d2462 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/ITenantRepository.cs @@ -0,0 +1,21 @@ +using AMREZ.EOP.Domain.Entities.Tenancy; + +namespace AMREZ.EOP.Abstractions.Infrastructures.Repositories; + +public interface ITenantRepository +{ + Task TenantExistsAsync(string tenantKey, CancellationToken ct = default); + Task GetAsync(string tenantKey, CancellationToken ct = default); + Task> ListTenantsAsync(CancellationToken ct = default); + + Task CreateAsync(TenantConfig row, CancellationToken ct = default); + Task UpdateAsync(TenantConfig row, DateTimeOffset? ifUnmodifiedSince, CancellationToken ct = default); + Task DeleteAsync(string tenantKey, CancellationToken ct = default); + + Task MapDomainAsync(string domain, string tenantKey, CancellationToken ct = default); + Task UnmapDomainAsync(string domain, CancellationToken ct = default); + Task> ListDomainsAsync(string? tenantKey, CancellationToken ct = default); + + Task AddBaseDomainAsync(string baseDomain, CancellationToken ct = default); + Task RemoveBaseDomainAsync(string baseDomain, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserProfileRepository.cs b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserProfileRepository.cs new file mode 100644 index 0000000..1742d7c --- /dev/null +++ b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserProfileRepository.cs @@ -0,0 +1,21 @@ +using AMREZ.EOP.Domain.Entities.HumanResources; + +namespace AMREZ.EOP.Abstractions.Infrastructures.Repositories; + +public interface IUserProfileRepository +{ + Task GetByUserIdAsync(Guid userId, CancellationToken ct = default); + Task UpsertAsync(UserProfile profile, CancellationToken ct = default); + + Task AddEmploymentAsync(Employment e, CancellationToken ct = default); + Task EndEmploymentAsync(Guid employmentId, DateTime endDate, CancellationToken ct = default); + + Task AddAddressAsync(EmployeeAddress a, CancellationToken ct = default); + Task SetPrimaryAddressAsync(Guid userProfileId, Guid addressId, CancellationToken ct = default); + + Task AddEmergencyContactAsync(EmergencyContact c, CancellationToken ct = default); + Task SetPrimaryEmergencyContactAsync(Guid userProfileId, Guid contactId, CancellationToken ct = default); + + Task AddBankAccountAsync(EmployeeBankAccount b, CancellationToken ct = default); + Task SetPrimaryBankAccountAsync(Guid userProfileId, Guid bankAccountId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserRepository.cs b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserRepository.cs new file mode 100644 index 0000000..36b3836 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Infrastructures/Repositories/IUserRepository.cs @@ -0,0 +1,31 @@ +using AMREZ.EOP.Domain.Entities.Authentications; +using AMREZ.EOP.Domain.Shared._Users; + +namespace AMREZ.EOP.Abstractions.Infrastructures.Repositories; + +public interface IUserRepository +{ + Task FindByIdAsync(Guid userId, CancellationToken ct = default); + Task FindActiveByEmailAsync(string email, CancellationToken ct = default); + Task EmailExistsAsync(string email, CancellationToken ct = default); + Task AddAsync(User user, CancellationToken ct = default); + + // Identities + Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default); + Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default); + Task GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default); + + // Password + Task ChangePasswordAsync(Guid userId, string newPasswordHash, CancellationToken ct = default); + Task AddPasswordHistoryAsync(Guid userId, string passwordHash, CancellationToken ct = default); + + // MFA + Task AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default); + Task DisableMfaFactorAsync(Guid factorId, CancellationToken ct = default); + Task HasAnyMfaAsync(Guid userId, CancellationToken ct = default); + + // Sessions + Task CreateSessionAsync(UserSession session, CancellationToken ct = default); + Task RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default); + Task RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Security/IPasswordHasher.cs b/AMREZ.EOP.Abstractions/Security/IPasswordHasher.cs new file mode 100644 index 0000000..abad85f --- /dev/null +++ b/AMREZ.EOP.Abstractions/Security/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Abstractions.Security; + +public interface IPasswordHasher +{ + bool Verify(string plain, string hash); + string Hash(string plain); +} \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Storage/IDbScope.cs b/AMREZ.EOP.Abstractions/Storage/IDbScope.cs new file mode 100644 index 0000000..5193223 --- /dev/null +++ b/AMREZ.EOP.Abstractions/Storage/IDbScope.cs @@ -0,0 +1,9 @@ +using AMREZ.EOP.Abstractions.Applications.Tenancy; + +namespace AMREZ.EOP.Abstractions.Storage; + +public interface IDbScope +{ + void EnsureForTenant(ITenantContext tenant); + TContext Get() where TContext : class; +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj b/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj new file mode 100644 index 0000000..b1fd7ad --- /dev/null +++ b/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/AMREZ.EOP.Application/UseCases/Authentications/AddEmailIdentityUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/AddEmailIdentityUseCase.cs new file mode 100644 index 0000000..ec66d32 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/AddEmailIdentityUseCase.cs @@ -0,0 +1,47 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity; +using AMREZ.EOP.Domain.Shared._Users; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class AddEmailIdentityUseCase : IAddEmailIdentityUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserRepository _users; + private readonly IHttpContextAccessor _http; + + public AddEmailIdentityUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _users = users; + _http = http; + } + + public async Task ExecuteAsync(AddEmailIdentityRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var email = request.Email.Trim().ToLowerInvariant(); + await _users.AddIdentityAsync(request.UserId, IdentityType.Email, email, request.IsPrimary, ct); + await _uow.CommitAsync(ct); + return true; + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Authentications/ChangePasswordUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/ChangePasswordUseCase.cs new file mode 100644 index 0000000..b3a499c --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/ChangePasswordUseCase.cs @@ -0,0 +1,47 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Abstractions.Security; +using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class ChangePasswordUseCase : IChangePasswordUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserRepository _users; + private readonly IPasswordHasher _hasher; + private readonly IHttpContextAccessor _http; + + public ChangePasswordUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IPasswordHasher h, IHttpContextAccessor http) + { _resolver = r; _uow = uow; _users = users; _hasher = h; _http = http; } + + public async Task ExecuteAsync(ChangePasswordRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var user = await _users.FindByIdAsync(request.UserId, ct); + if (user is null) { await _uow.RollbackAsync(ct); return false; } + + if (!_hasher.Verify(request.OldPassword, user.PasswordHash)) + { await _uow.RollbackAsync(ct); return false; } + + var newHash = _hasher.Hash(request.NewPassword); + await _users.AddPasswordHistoryAsync(user.Id, user.PasswordHash, ct); + await _users.ChangePasswordAsync(user.Id, newHash, ct); + + await _uow.CommitAsync(ct); + return true; + } + catch { await _uow.RollbackAsync(ct); throw; } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Authentications/DisableMfaUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/DisableMfaUseCase.cs new file mode 100644 index 0000000..067d06d --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/DisableMfaUseCase.cs @@ -0,0 +1,36 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class DisableMfaUseCase : IDisableMfaUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserRepository _users; + private readonly IHttpContextAccessor _http; + + public DisableMfaUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http) + { _resolver = r; _uow = uow; _users = users; _http = http; } + + public async Task ExecuteAsync(DisableMfaRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + await _users.DisableMfaFactorAsync(request.FactorId, ct); + await _uow.CommitAsync(ct); + return true; + } + catch { await _uow.RollbackAsync(ct); throw; } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Authentications/EnableTotpUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/EnableTotpUseCase.cs new file mode 100644 index 0000000..539a3ed --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/EnableTotpUseCase.cs @@ -0,0 +1,36 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class EnableTotpUseCase : IEnableTotpUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserRepository _users; + private readonly IHttpContextAccessor _http; + + public EnableTotpUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http) + { _resolver = r; _uow = uow; _users = users; _http = http; } + + public async Task ExecuteAsync(EnableTotpRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return null; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var factor = await _users.AddTotpFactorAsync(request.UserId, request.Label, request.Secret, ct); + await _uow.CommitAsync(ct); + return new EnableTotpResponse(factor.Id, request.Label); + } + catch { await _uow.RollbackAsync(ct); throw; } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Authentications/LoginUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/LoginUseCase.cs new file mode 100644 index 0000000..dfa2df1 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/LoginUseCase.cs @@ -0,0 +1,51 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Abstractions.Security; +using AMREZ.EOP.Contracts.DTOs.Authentications.Login; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class LoginUseCase : ILoginUseCase +{ + private readonly ITenantResolver _tenantResolver; + private readonly IUserRepository _users; + private readonly IPasswordHasher _hasher; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + + public LoginUseCase(ITenantResolver r, IUserRepository u, IPasswordHasher h, IHttpContextAccessor http, IUnitOfWork uow) + { _tenantResolver = r; _users = u; _hasher = h; _http = http; _uow = uow; } + + public async Task ExecuteAsync(LoginRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _tenantResolver.Resolve(http, request); + if (tenant is null) return null; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var email = request.Email.Trim().ToLowerInvariant(); + var user = await _users.FindActiveByEmailAsync(email, ct); + if (user is null || !_hasher.Verify(request.Password, user.PasswordHash)) + { + await _uow.RollbackAsync(ct); + return null; + } + + await _uow.CommitAsync(ct); + + // NOTE: ไม่ใช้ DisplayName ใน Entity แล้ว — ส่งกลับเป็นค่าว่าง/ไปดึงจาก HR ฝั่ง API + return new LoginResponse(user.Id, string.Empty, email, tenant.Id); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Authentications/LogoutAllUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/LogoutAllUseCase.cs new file mode 100644 index 0000000..3d9d255 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/LogoutAllUseCase.cs @@ -0,0 +1,45 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class LogoutAllUseCase : ILogoutAllUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserRepository _users; + private readonly IHttpContextAccessor _http; + + public LogoutAllUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _users = users; + _http = http; + } + + public async Task ExecuteAsync(LogoutAllRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return 0; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var n = await _users.RevokeAllSessionsAsync(request.UserId, ct); + await _uow.CommitAsync(ct); + return n; + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Authentications/LogoutUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/LogoutUseCase.cs new file mode 100644 index 0000000..da25aa8 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/LogoutUseCase.cs @@ -0,0 +1,36 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Authentications.Logout; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class LogoutUseCase : ILogoutUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserRepository _users; + private readonly IHttpContextAccessor _http; + + public LogoutUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http) + { _resolver = r; _uow = uow; _users = users; _http = http; } + + public async Task ExecuteAsync(LogoutRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var n = await _users.RevokeSessionAsync(request.UserId, request.SessionId, ct); + await _uow.CommitAsync(ct); + return n > 0; + } + catch { await _uow.RollbackAsync(ct); throw; } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Authentications/RegisterUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/RegisterUseCase.cs new file mode 100644 index 0000000..034f6c6 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/RegisterUseCase.cs @@ -0,0 +1,78 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Abstractions.Security; +using AMREZ.EOP.Contracts.DTOs.Authentications.Register; +using AMREZ.EOP.Domain.Entities.Authentications; +using AMREZ.EOP.Domain.Shared._Users; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class RegisterUseCase : IRegisterUseCase +{ + private readonly ITenantResolver _tenantResolver; + private readonly IUnitOfWork _uow; + private readonly IUserRepository _users; + private readonly IPasswordHasher _hasher; + private readonly IHttpContextAccessor _http; + + public RegisterUseCase( + ITenantResolver resolver, + IUnitOfWork uow, + IUserRepository users, + IPasswordHasher hasher, + IHttpContextAccessor http) + { + _tenantResolver = resolver; _uow = uow; _users = users; _hasher = hasher; _http = http; + } + + public async Task ExecuteAsync(RegisterRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _tenantResolver.Resolve(http, request); + if (tenant is null) return null; + + var emailNorm = request.Email.Trim().ToLowerInvariant(); + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + if (await _users.EmailExistsAsync(emailNorm, ct)) + { + await _uow.RollbackAsync(ct); + return null; + } + + var hash = _hasher.Hash(request.Password); + + var user = new User + { + PasswordHash = hash, + IsActive = true, + }; + + // แนบอัตลักษณ์แบบ Email (เก็บในตารางลูก) + user.Identities.Add(new UserIdentity + { + Type = IdentityType.Email, + Identifier = emailNorm, + IsPrimary = true, + VerifiedAt = null + }); + + await _users.AddAsync(user, ct); + await _uow.CommitAsync(ct); + + // ไม่ส่ง DisplayName (ปล่อยให้ HR/Presentation สร้าง) + return new RegisterResponse(user.Id, string.Empty, emailNorm, tenant.Id); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Authentications/VerifyEmailUseCase.cs b/AMREZ.EOP.Application/UseCases/Authentications/VerifyEmailUseCase.cs new file mode 100644 index 0000000..e4f0b7e --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Authentications/VerifyEmailUseCase.cs @@ -0,0 +1,37 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail; +using AMREZ.EOP.Domain.Shared._Users; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Authentications; + +public sealed class VerifyEmailUseCase : IVerifyEmailUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserRepository _users; + private readonly IHttpContextAccessor _http; + + public VerifyEmailUseCase(ITenantResolver r, IUnitOfWork uow, IUserRepository users, IHttpContextAccessor http) + { _resolver = r; _uow = uow; _users = users; _http = http; } + + public async Task ExecuteAsync(VerifyEmailRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + await _users.VerifyIdentityAsync(request.UserId, IdentityType.Email, request.Email.Trim().ToLowerInvariant(), DateTimeOffset.UtcNow, ct); + await _uow.CommitAsync(ct); + return true; + } + catch { await _uow.RollbackAsync(ct); throw; } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/AddEmergencyContactUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmergencyContactUseCase.cs new file mode 100644 index 0000000..65be24e --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmergencyContactUseCase.cs @@ -0,0 +1,51 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContact; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd; +using AMREZ.EOP.Domain.Entities.HumanResources; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class AddEmergencyContactUseCase : IAddEmergencyContactUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public AddEmergencyContactUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, IHttpContextAccessor http) + { _resolver = r; _uow = uow; _hr = hr; _http = http; } + + public async Task ExecuteAsync(EmergencyContactAddRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return null; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var ec = new EmergencyContact + { + UserProfileId = request.UserProfileId, + Name = request.Name, + Relationship = request.Relationship, + Phone = request.Phone, + Email = request.Email, + IsPrimary = request.IsPrimary + }; + + var added = await _hr.AddEmergencyContactAsync(ec, ct); + if (request.IsPrimary) + await _hr.SetPrimaryEmergencyContactAsync(request.UserProfileId, added.Id, ct); + + await _uow.CommitAsync(ct); + return new EmergencyContactResponse(added.Id, added.UserProfileId, added.IsPrimary); + } + catch { await _uow.RollbackAsync(ct); throw; } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeAddressUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeAddressUseCase.cs new file mode 100644 index 0000000..5361637 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeAddressUseCase.cs @@ -0,0 +1,65 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddress; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd; +using AMREZ.EOP.Domain.Entities.HumanResources; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class AddEmployeeAddressUseCase : IAddEmployeeAddressUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public AddEmployeeAddressUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, + IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _hr = hr; + _http = http; + } + + public async Task ExecuteAsync(EmployeeAddressAddRequest request, + CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return null; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var addr = new EmployeeAddress + { + UserProfileId = request.UserProfileId, + Type = request.Type, + Line1 = request.Line1, + Line2 = request.Line2, + City = request.City, + State = request.State, + PostalCode = request.PostalCode, + Country = request.Country, + IsPrimary = request.IsPrimary + }; + + var added = await _hr.AddAddressAsync(addr, ct); + if (request.IsPrimary) + await _hr.SetPrimaryAddressAsync(request.UserProfileId, added.Id, ct); + + await _uow.CommitAsync(ct); + return new EmployeeAddressResponse(added.Id, added.UserProfileId, added.IsPrimary); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeBankAccountUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeBankAccountUseCase.cs new file mode 100644 index 0000000..6e4f425 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmployeeBankAccountUseCase.cs @@ -0,0 +1,63 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccount; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd; +using AMREZ.EOP.Domain.Entities.HumanResources; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class AddEmployeeBankAccountUseCase : IAddEmployeeBankAccountUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public AddEmployeeBankAccountUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, + IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _hr = hr; + _http = http; + } + + public async Task ExecuteAsync(EmployeeBankAccountAddRequest request, + CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return null; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var b = new EmployeeBankAccount + { + UserProfileId = request.UserProfileId, + BankName = request.BankName, + AccountNumber = request.AccountNumber, + AccountHolder = request.AccountHolder, + Branch = request.Branch, + Note = request.Note, + IsPrimary = request.IsPrimary + }; + + var added = await _hr.AddBankAccountAsync(b, ct); + if (request.IsPrimary) + await _hr.SetPrimaryBankAccountAsync(request.UserProfileId, added.Id, ct); + + await _uow.CommitAsync(ct); + return new EmployeeBankAccountResponse(added.Id, added.UserProfileId, added.IsPrimary); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/AddEmploymentUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmploymentUseCase.cs new file mode 100644 index 0000000..76fa3c9 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/AddEmploymentUseCase.cs @@ -0,0 +1,60 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.Employment; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd; +using AMREZ.EOP.Domain.Entities.HumanResources; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class AddEmploymentUseCase : IAddEmploymentUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public AddEmploymentUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, + IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _hr = hr; + _http = http; + } + + public async Task ExecuteAsync(EmploymentAddRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return null; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var e = new Employment + { + UserProfileId = request.UserProfileId, + EmploymentType = request.EmploymentType, + StartDate = request.StartDate, + DepartmentId = request.DepartmentId, + PositionId = request.PositionId, + ManagerUserId = request.ManagerUserId, + WorkEmail = request.WorkEmail, + WorkPhone = request.WorkPhone + }; + + var added = await _hr.AddEmploymentAsync(e, ct); + await _uow.CommitAsync(ct); + return new EmploymentResponse(added.Id, added.UserProfileId, added.StartDate, added.EndDate); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/EndEmploymentUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/EndEmploymentUseCase.cs new file mode 100644 index 0000000..456eeeb --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/EndEmploymentUseCase.cs @@ -0,0 +1,46 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class EndEmploymentUseCase : IEndEmploymentUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public EndEmploymentUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, + IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _hr = hr; + _http = http; + } + + public async Task ExecuteAsync(EmploymentEndRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + await _hr.EndEmploymentAsync(request.EmploymentId, request.EndDate, ct); + await _uow.CommitAsync(ct); + return true; + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryAddressUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryAddressUseCase.cs new file mode 100644 index 0000000..765f4ce --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryAddressUseCase.cs @@ -0,0 +1,46 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class SetPrimaryAddressUseCase : ISetPrimaryAddressUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public SetPrimaryAddressUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, + IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _hr = hr; + _http = http; + } + + public async Task ExecuteAsync(SetPrimaryAddressRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + await _hr.SetPrimaryAddressAsync(request.UserProfileId, request.AddressId, ct); + await _uow.CommitAsync(ct); + return true; + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryBankAccountUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryBankAccountUseCase.cs new file mode 100644 index 0000000..968d6a3 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryBankAccountUseCase.cs @@ -0,0 +1,46 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class SetPrimaryBankAccountUseCase : ISetPrimaryBankAccountUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public SetPrimaryBankAccountUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, + IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _hr = hr; + _http = http; + } + + public async Task ExecuteAsync(SetPrimaryBankAccountRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + await _hr.SetPrimaryBankAccountAsync(request.UserProfileId, request.BankAccountId, ct); + await _uow.CommitAsync(ct); + return true; + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryEmergencyContactUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryEmergencyContactUseCase.cs new file mode 100644 index 0000000..7960b14 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/SetPrimaryEmergencyContactUseCase.cs @@ -0,0 +1,46 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class SetPrimaryEmergencyContactUseCase : ISetPrimaryEmergencyContactUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public SetPrimaryEmergencyContactUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, + IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _hr = hr; + _http = http; + } + + public async Task ExecuteAsync(SetPrimaryEmergencyContactRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return false; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + await _hr.SetPrimaryEmergencyContactAsync(request.UserProfileId, request.ContactId, ct); + await _uow.CommitAsync(ct); + return true; + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/HumanResources/UpsertUserProfileUseCase.cs b/AMREZ.EOP.Application/UseCases/HumanResources/UpsertUserProfileUseCase.cs new file mode 100644 index 0000000..71e898f --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/HumanResources/UpsertUserProfileUseCase.cs @@ -0,0 +1,59 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfile; +using AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert; +using AMREZ.EOP.Domain.Entities.HumanResources; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.HumanResources; + +public sealed class UpsertUserProfileUseCase : IUpsertUserProfileUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IUnitOfWork _uow; + private readonly IUserProfileRepository _hr; + private readonly IHttpContextAccessor _http; + + public UpsertUserProfileUseCase(ITenantResolver r, IUnitOfWork uow, IUserProfileRepository hr, + IHttpContextAccessor http) + { + _resolver = r; + _uow = uow; + _hr = hr; + _http = http; + } + + public async Task ExecuteAsync(UserProfileUpsertRequest request, + CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return null; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var current = await _hr.GetByUserIdAsync(request.UserId, ct); + var entity = current ?? new UserProfile { UserId = request.UserId }; + entity.FirstName = request.FirstName; + entity.LastName = request.LastName; + entity.MiddleName = request.MiddleName; + entity.Nickname = request.Nickname; + entity.DateOfBirth = request.DateOfBirth; + entity.Gender = request.Gender; + + await _hr.UpsertAsync(entity, ct); + await _uow.CommitAsync(ct); + + return new UserProfileResponse(entity.Id, entity.UserId, entity.FirstName, entity.LastName); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/AddBaseDomainUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/AddBaseDomainUseCase.cs new file mode 100644 index 0000000..93ff51b --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/AddBaseDomainUseCase.cs @@ -0,0 +1,46 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class AddBaseDomainUseCase : IAddBaseDomainUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + + public AddBaseDomainUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo) + { _resolver = resolver; _http = http; _uow = uow; _repo = repo; } + + public async Task ExecuteAsync(AddBaseDomainRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + + var ctx = _resolver.Resolve(http, hint: null); + if (ctx is null) return null; + + var baseDomain = (request.BaseDomain ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(baseDomain)) return null; + + await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct); + try + { + var ok = await _repo.AddBaseDomainAsync(baseDomain, ct); // repo ดึง target tenant จาก X-Tenant/context + if (!ok) { await _uow.RollbackAsync(ct); return null; } + + await _uow.CommitAsync(ct); + return new AddBaseDomainResponse(baseDomain); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/CreateTenantUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/CreateTenantUseCase.cs new file mode 100644 index 0000000..a85f4e6 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/CreateTenantUseCase.cs @@ -0,0 +1,77 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant; +using AMREZ.EOP.Domain.Entities.Tenancy; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class CreateTenantUseCase : ICreateTenantUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + private readonly ITenantProvisioner _provisioner; + + + public CreateTenantUseCase( + ITenantResolver resolver, + IHttpContextAccessor http, + IUnitOfWork uow, + ITenantRepository repo, + ITenantProvisioner provisioner + ) + { + _resolver = resolver; + _http = http; + _uow = uow; + _repo = repo; + _provisioner = provisioner; + } + + public async Task ExecuteAsync(CreateTenantRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var tenant = _resolver.Resolve(http, request); + if (tenant is null) return null; + + await _uow.BeginAsync(tenant, IsolationLevel.ReadCommitted, ct); + try + { + var key = request.TenantKey.Trim().ToLowerInvariant(); + if (await _repo.TenantExistsAsync(key, ct)) + { + await _uow.RollbackAsync(ct); + return null; + } + + var row = new TenantConfig + { + TenantKey = key, + Schema = string.IsNullOrWhiteSpace(request.Schema) ? null : request.Schema!.Trim(), + ConnectionString = string.IsNullOrWhiteSpace(request.ConnectionString) ? null : request.ConnectionString!.Trim(), + Mode = request.Mode, + IsActive = request.IsActive, + UpdatedAtUtc = DateTimeOffset.UtcNow + }; + + var ok = await _repo.CreateAsync(row, ct); + if (!ok) { await _uow.RollbackAsync(ct); return null; } + + await _uow.CommitAsync(ct); + + await _provisioner.ProvisionAsync(key, ct); + + return new CreateTenantResponse(key); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/DeleteTenantUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/DeleteTenantUseCase.cs new file mode 100644 index 0000000..0ab498e --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/DeleteTenantUseCase.cs @@ -0,0 +1,43 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class DeleteTenantUseCase : IDeleteTenantUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + + public DeleteTenantUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo) + { _resolver = resolver; _http = http; _uow = uow; _repo = repo; } + + public async Task ExecuteAsync(string tenantKey, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var key = (tenantKey ?? string.Empty).Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(key)) return false; + + var ctx = _resolver.Resolve(http, hint: null); + if (ctx is null) return false; + + await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct); + try + { + var ok = await _repo.DeleteAsync(key, ct); + if (!ok) { await _uow.RollbackAsync(ct); return false; } + await _uow.CommitAsync(ct); + return true; + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/ListDomainsUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/ListDomainsUseCase.cs new file mode 100644 index 0000000..10513d2 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/ListDomainsUseCase.cs @@ -0,0 +1,45 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class ListDomainsUseCase : IListDomainsUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + + public ListDomainsUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo) + { _resolver = resolver; _http = http; _uow = uow; _repo = repo; } + + public async Task ExecuteAsync(ListDomainsRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var key = string.IsNullOrWhiteSpace(request?.TenantKey) ? null : request!.TenantKey!.Trim().ToLowerInvariant(); + + var ctx = _resolver.Resolve(http, hint: null); + if (ctx is null) return null; + + await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct); + try + { + var items = (await _repo.ListDomainsAsync(key, ct)) + .Select(x => new DomainDto(x.Domain, x.TenantKey, x.IsPlatformBaseDomain, x.IsActive, x.UpdatedAtUtc)) + .ToList(); + + await _uow.CommitAsync(ct); + return new ListDomainsResponse(items); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/ListTenantsUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/ListTenantsUseCase.cs new file mode 100644 index 0000000..5fdd4cc --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/ListTenantsUseCase.cs @@ -0,0 +1,43 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class ListTenantsUseCase : IListTenantsUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + + public ListTenantsUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo) + { _resolver = resolver; _http = http; _uow = uow; _repo = repo; } + + public async Task ExecuteAsync(ListTenantsRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + var ctx = _resolver.Resolve(http, hint: null); + if (ctx is null) return null; + + await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct); + try + { + var items = (await _repo.ListTenantsAsync(ct)) + .Select(x => new TenantDto(x.TenantKey, x.Schema, x.ConnectionString, x.Mode, x.IsActive, x.UpdatedAtUtc)) + .ToList(); + + await _uow.CommitAsync(ct); + return new ListTenantsResponse(items); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/MapDomainUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/MapDomainUseCase.cs new file mode 100644 index 0000000..0ca651c --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/MapDomainUseCase.cs @@ -0,0 +1,47 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class MapDomainUseCase : IMapDomainUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + + public MapDomainUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo) + { _resolver = resolver; _http = http; _uow = uow; _repo = repo; } + + public async Task ExecuteAsync(MapDomainRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + + var key = string.IsNullOrWhiteSpace(request?.TenantKey) ? null : request!.TenantKey!.Trim().ToLowerInvariant(); + var domain = (request?.Domain ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(domain)) return null; + + var ctx = _resolver.Resolve(http, hint: null); + if (ctx is null) return null; + + await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct); + try + { + var ok = await _repo.MapDomainAsync(domain, key, ct); + if (!ok) { await _uow.RollbackAsync(ct); return null; } + + await _uow.CommitAsync(ct); + return new MapDomainResponse(domain, key); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/RemoveBaseDomainUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/RemoveBaseDomainUseCase.cs new file mode 100644 index 0000000..8934b66 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/RemoveBaseDomainUseCase.cs @@ -0,0 +1,47 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class RemoveBaseDomainUseCase : IRemoveBaseDomainUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + + public RemoveBaseDomainUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo) + { _resolver = resolver; _http = http; _uow = uow; _repo = repo; } + + public async Task ExecuteAsync(RemoveBaseDomainRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + + var key = string.IsNullOrWhiteSpace(request?.TenantKey) ? null : request!.TenantKey!.Trim().ToLowerInvariant(); + var baseDomain = (request?.BaseDomain ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(baseDomain)) return null; + + var ctx = _resolver.Resolve(http, hint: null); + if (ctx is null) return null; + + await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct); + try + { + var ok = await _repo.RemoveBaseDomainAsync(baseDomain, ct); + if (!ok) { await _uow.RollbackAsync(ct); return null; } + + await _uow.CommitAsync(ct); + return new RemoveBaseDomainResponse(baseDomain); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/UnmapDomainUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/UnmapDomainUseCase.cs new file mode 100644 index 0000000..8f51af6 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/UnmapDomainUseCase.cs @@ -0,0 +1,47 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class UnmapDomainUseCase : IUnmapDomainUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + + public UnmapDomainUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo) + { _resolver = resolver; _http = http; _uow = uow; _repo = repo; } + + public async Task ExecuteAsync(UnmapDomainRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + + var key = string.IsNullOrWhiteSpace(request?.TenantKey) ? null : request!.TenantKey!.Trim().ToLowerInvariant(); + var domain = (request?.Domain ?? string.Empty).Trim().TrimEnd('.').ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(domain)) return null; + + var ctx = _resolver.Resolve(http, hint: null); + if (ctx is null) return null; + + await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct); + try + { + var ok = await _repo.UnmapDomainAsync(domain, ct); + if (!ok) { await _uow.RollbackAsync(ct); return null; } + + await _uow.CommitAsync(ct); + return new UnmapDomainResponse(domain); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Tenancy/UpdateTenantUseCase.cs b/AMREZ.EOP.Application/UseCases/Tenancy/UpdateTenantUseCase.cs new file mode 100644 index 0000000..f3fc9f6 --- /dev/null +++ b/AMREZ.EOP.Application/UseCases/Tenancy/UpdateTenantUseCase.cs @@ -0,0 +1,58 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant; +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Application.UseCases.Tenancy; + +public sealed class UpdateTenantUseCase : IUpdateTenantUseCase +{ + private readonly ITenantResolver _resolver; + private readonly IHttpContextAccessor _http; + private readonly IUnitOfWork _uow; + private readonly ITenantRepository _repo; + + public UpdateTenantUseCase(ITenantResolver resolver, IHttpContextAccessor http, IUnitOfWork uow, ITenantRepository repo) + { _resolver = resolver; _http = http; _uow = uow; _repo = repo; } + + public async Task ExecuteAsync(UpdateTenantRequest request, CancellationToken ct = default) + { + var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); + + var key = (request?.TenantKey ?? string.Empty).Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(key)) return null; + + var ctx = _resolver.Resolve(http, hint: null); + if (ctx is null) return null; + + await _uow.BeginAsync(ctx, IsolationLevel.ReadCommitted, ct); + try + { + var current = await _repo.GetAsync(key, ct); + if (current is null) { await _uow.RollbackAsync(ct); return null; } // 404 + + if (request!.IfUnmodifiedSince.HasValue && current.UpdatedAtUtc > request.IfUnmodifiedSince.Value) + { await _uow.RollbackAsync(ct); return null; } // 412 + + current.Schema = request.Schema is null ? current.Schema : (string.IsNullOrWhiteSpace(request.Schema) ? null : request.Schema.Trim()); + current.ConnectionString = request.ConnectionString is null ? current.ConnectionString : (string.IsNullOrWhiteSpace(request.ConnectionString) ? null : request.ConnectionString.Trim()); + if (request.Mode.HasValue) current.Mode = request.Mode.Value; + if (request.IsActive.HasValue) current.IsActive = request.IsActive.Value; + current.UpdatedAtUtc = DateTimeOffset.UtcNow; + + var ok = await _repo.UpdateAsync(current, request.IfUnmodifiedSince, ct); + if (!ok) { await _uow.RollbackAsync(ct); return null; } + + await _uow.CommitAsync(ct); + return new UpdateTenantResponse(current.TenantKey, current.UpdatedAtUtc); + } + catch + { + await _uow.RollbackAsync(ct); + throw; + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/AMREZ.EOP.Contracts.csproj b/AMREZ.EOP.Contracts/AMREZ.EOP.Contracts.csproj new file mode 100644 index 0000000..d2e9fb9 --- /dev/null +++ b/AMREZ.EOP.Contracts/AMREZ.EOP.Contracts.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/AddEmailIdentity/AddEmailIdentityRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/AddEmailIdentity/AddEmailIdentityRequest.cs new file mode 100644 index 0000000..57fc618 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/AddEmailIdentity/AddEmailIdentityRequest.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity; + +public sealed class AddEmailIdentityRequest +{ + public Guid UserId { get; set; } + public string Email { get; set; } = default!; + public bool IsPrimary { get; set; } = true; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/ChangePassword/ChangePasswordRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/ChangePassword/ChangePasswordRequest.cs new file mode 100644 index 0000000..3be2d5b --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/ChangePassword/ChangePasswordRequest.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword; + +public sealed class ChangePasswordRequest +{ + public Guid UserId { get; set; } + public string OldPassword { get; set; } = default!; + public string NewPassword { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/DisableMfa/DisableMfaRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/DisableMfa/DisableMfaRequest.cs new file mode 100644 index 0000000..00e7cd5 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/DisableMfa/DisableMfaRequest.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa; + +public sealed class DisableMfaRequest +{ + public Guid FactorId { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpRequest.cs new file mode 100644 index 0000000..758fbe3 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpRequest.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp; + +public sealed class EnableTotpRequest +{ + public Guid UserId { get; set; } + public string Label { get; set; } = default!; + public string Secret { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpResponse.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpResponse.cs new file mode 100644 index 0000000..de26326 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/EnableTotp/EnableTotpResponse.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp; + +public sealed record EnableTotpResponse( + Guid FactorId, + string Label +); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginRequest.cs new file mode 100644 index 0000000..4f2e280 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginRequest.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.Login; + +public sealed class LoginRequest +{ + public string? Tenant { get; set; } + public string Email { get; set; } = default!; + public string Password { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginResponse.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginResponse.cs new file mode 100644 index 0000000..882aff1 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Login/LoginResponse.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.Login; + +public sealed record LoginResponse( + Guid UserId, + string DisplayName, + string Email, + string TenantId +); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Logout/LogoutRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Logout/LogoutRequest.cs new file mode 100644 index 0000000..a679594 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Logout/LogoutRequest.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.Logout; + +public sealed class LogoutRequest +{ + public Guid UserId { get; set; } + public Guid SessionId { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/LogoutAll/LogoutAllRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/LogoutAll/LogoutAllRequest.cs new file mode 100644 index 0000000..879d649 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/LogoutAll/LogoutAllRequest.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll; + +public sealed class LogoutAllRequest +{ + public Guid UserId { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterRequest.cs new file mode 100644 index 0000000..a444822 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterRequest.cs @@ -0,0 +1,9 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.Register; + +public sealed class RegisterRequest +{ + public string? Tenant { get; set; } + public string Email { get; set; } = default!; + public string Password{ get; set; } = default!; + public string Name { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterResponse.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterResponse.cs new file mode 100644 index 0000000..088703b --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/Register/RegisterResponse.cs @@ -0,0 +1,3 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.Register; + +public sealed record RegisterResponse(Guid UserId, string Name, string Email, string Tenant); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Authentications/VerifyEmail/VerifyEmailRequest.cs b/AMREZ.EOP.Contracts/DTOs/Authentications/VerifyEmail/VerifyEmailRequest.cs new file mode 100644 index 0000000..5fcc77a --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Authentications/VerifyEmail/VerifyEmailRequest.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail; + +public sealed class VerifyEmailRequest +{ + public Guid UserId { get; set; } + public string Email { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContact/EmergencyContactResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContact/EmergencyContactResponse.cs new file mode 100644 index 0000000..ba6bff7 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContact/EmergencyContactResponse.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContact; + +public sealed record EmergencyContactResponse( + Guid Id, + Guid UserProfileId, + bool IsPrimary +); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContactAdd/EmergencyContactAddRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContactAdd/EmergencyContactAddRequest.cs new file mode 100644 index 0000000..aa5cf75 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmergencyContactAdd/EmergencyContactAddRequest.cs @@ -0,0 +1,11 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmergencyContactAdd; + +public sealed class EmergencyContactAddRequest +{ + public Guid UserProfileId { get; set; } + public string Name { get; set; } = default!; + public string Relationship { get; set; } = default!; + public string? Phone { get; set; } + public string? Email { get; set; } + public bool IsPrimary { get; set; } = false; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddress/EmployeeAddressResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddress/EmployeeAddressResponse.cs new file mode 100644 index 0000000..a05a2b7 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddress/EmployeeAddressResponse.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddress; + +public sealed record EmployeeAddressResponse( + Guid Id, + Guid UserProfileId, + bool IsPrimary +); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddressAdd/EmployeeAddressAddRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddressAdd/EmployeeAddressAddRequest.cs new file mode 100644 index 0000000..26f4ef9 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeAddressAdd/EmployeeAddressAddRequest.cs @@ -0,0 +1,18 @@ +using AMREZ.EOP.Domain.Shared.HumanResources; + +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeAddressAdd; + +public sealed class EmployeeAddressAddRequest +{ + public Guid UserProfileId { get; set; } + public AddressType Type { get; set; } = AddressType.Home; + + public string Line1 { get; set; } = default!; + public string? Line2 { get; set; } + public string City { get; set; } = default!; + public string? State { get; set; } + public string PostalCode{ get; set; } = default!; + public string Country { get; set; } = default!; + + public bool IsPrimary { get; set; } = false; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccount/EmployeeBankAccountResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccount/EmployeeBankAccountResponse.cs new file mode 100644 index 0000000..e058327 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccount/EmployeeBankAccountResponse.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccount; + +public sealed record EmployeeBankAccountResponse( + Guid Id, + Guid UserProfileId, + bool IsPrimary +); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccountAdd/EmployeeBankAccountAddRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccountAdd/EmployeeBankAccountAddRequest.cs new file mode 100644 index 0000000..d211345 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmployeeBankAccountAdd/EmployeeBankAccountAddRequest.cs @@ -0,0 +1,12 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmployeeBankAccountAdd; + +public sealed class EmployeeBankAccountAddRequest +{ + public Guid UserProfileId { get; set; } + public string BankName { get; set; } = default!; + public string AccountNumber { get; set; } = default!; + public string AccountHolder { get; set; } = default!; + public string? Branch { get; set; } + public string? Note { get; set; } + public bool IsPrimary { get; set; } = false; +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/Employment/EmploymentResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/Employment/EmploymentResponse.cs new file mode 100644 index 0000000..56ed0d6 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/Employment/EmploymentResponse.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.Employment; + +public sealed record EmploymentResponse( + Guid Id, + Guid UserProfileId, + DateTime StartDate, + DateTime? EndDate +); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentAdd/EmploymentAddRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentAdd/EmploymentAddRequest.cs new file mode 100644 index 0000000..27ca971 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentAdd/EmploymentAddRequest.cs @@ -0,0 +1,15 @@ +using AMREZ.EOP.Domain.Shared.HumanResources; + +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentAdd; + +public sealed class EmploymentAddRequest +{ + public Guid UserProfileId { get; set; } + public EmploymentType EmploymentType { get; set; } = EmploymentType.Permanent; + public DateTime StartDate { get; set; } + public Guid? DepartmentId { get; set; } + public Guid? PositionId { get; set; } + public Guid? ManagerUserId{ get; set; } + public string? WorkEmail { get; set; } + public string? WorkPhone { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentEnd/EmploymentEndRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentEnd/EmploymentEndRequest.cs new file mode 100644 index 0000000..c44004c --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/EmploymentEnd/EmploymentEndRequest.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.EmploymentEnd; + +public sealed class EmploymentEndRequest +{ + public Guid EmploymentId { get; set; } + public DateTime EndDate { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryAddress/SetPrimaryAddressRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryAddress/SetPrimaryAddressRequest.cs new file mode 100644 index 0000000..f561214 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryAddress/SetPrimaryAddressRequest.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryAddress; + +public sealed class SetPrimaryAddressRequest +{ + public Guid UserProfileId { get; set; } + public Guid AddressId { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryBankAccount/SetPrimaryBankAccountRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryBankAccount/SetPrimaryBankAccountRequest.cs new file mode 100644 index 0000000..d2211fa --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryBankAccount/SetPrimaryBankAccountRequest.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryBankAccount; + +public sealed class SetPrimaryBankAccountRequest +{ + public Guid UserProfileId { get; set; } + public Guid BankAccountId { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryEmergencyContact/SetPrimaryEmergencyContactRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryEmergencyContact/SetPrimaryEmergencyContactRequest.cs new file mode 100644 index 0000000..9eb20ef --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/SetPrimaryEmergencyContact/SetPrimaryEmergencyContactRequest.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.SetPrimaryEmergencyContact; + +public sealed class SetPrimaryEmergencyContactRequest +{ + public Guid UserProfileId { get; set; } + public Guid ContactId { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfile/UserProfileResponse.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfile/UserProfileResponse.cs new file mode 100644 index 0000000..67a57e3 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfile/UserProfileResponse.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfile; + +public sealed record UserProfileResponse( + Guid Id, + Guid UserId, + string FirstName, + string LastName +); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfileUpsert/UserProfileUpsertRequest.cs b/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfileUpsert/UserProfileUpsertRequest.cs new file mode 100644 index 0000000..ec9eb7e --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/HumanResources/UserProfileUpsert/UserProfileUpsertRequest.cs @@ -0,0 +1,14 @@ +using AMREZ.EOP.Domain.Shared.HumanResources; + +namespace AMREZ.EOP.Contracts.DTOs.HumanResources.UserProfileUpsert; + +public sealed class UserProfileUpsertRequest +{ + public Guid UserId { get; set; } + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public string? MiddleName { get; set; } + public string? Nickname { get; set; } + public DateTime? DateOfBirth { get; set; } + public Gender? Gender { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainRequest.cs new file mode 100644 index 0000000..ccbbf80 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainRequest.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain; + +public sealed class AddBaseDomainRequest +{ + public string TenantKey { get; set; } = default!; + public string BaseDomain { get; set; } = default!; + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainResponse.cs new file mode 100644 index 0000000..3f2b4ca --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/AddBaseDomain/AddBaseDomainResponse.cs @@ -0,0 +1,5 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.AddBaseDomain; + +public sealed record AddBaseDomainResponse( + string BaseDomain + ); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantRequest.cs new file mode 100644 index 0000000..18c4e9e --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantRequest.cs @@ -0,0 +1,14 @@ +using AMREZ.EOP.Domain.Shared.Tenancy; + +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant; + +public sealed class CreateTenantRequest +{ + public string TenantKey { get; set; } = default!; + public string? Schema { get; set; } + public string? ConnectionString { get; set; } + public TenantMode Mode { get; set; } = TenantMode.Rls; + public bool IsActive { get; set; } = true; + + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantResponse.cs new file mode 100644 index 0000000..3866616 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/CreateTenant/CreateTenantResponse.cs @@ -0,0 +1,5 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.CreateTenant; + +public sealed record CreateTenantResponse( + string TenantKey + ); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsRequest.cs new file mode 100644 index 0000000..9c619cc --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsRequest.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains; + +public sealed class ListDomainsRequest +{ + public string? TenantKey { get; set; } + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsResponse.cs new file mode 100644 index 0000000..bc4c109 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListDomains/ListDomainsResponse.cs @@ -0,0 +1,12 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.ListDomains; + +public sealed record DomainDto( + string Domain, + string? TenantKey, + bool IsPlatformBaseDomain, + bool IsActive, + DateTimeOffset UpdatedAtUtc); + +public sealed record ListDomainsResponse( + IReadOnlyList Items + ); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsRequest.cs new file mode 100644 index 0000000..0a3bba0 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsRequest.cs @@ -0,0 +1,7 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants; + +public sealed class ListTenantsRequest +{ + public string TenantKey { get; set; } = default!; + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsResponse.cs new file mode 100644 index 0000000..45ea1d6 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/ListTenants/ListTenantsResponse.cs @@ -0,0 +1,15 @@ +using AMREZ.EOP.Domain.Shared.Tenancy; + +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.ListTenants; + +public sealed record TenantDto( + string TenantKey, + string? Schema, + string? ConnectionString, + TenantMode Mode, + bool IsActive, + DateTimeOffset UpdatedAtUtc); + +public sealed record ListTenantsResponse( + IReadOnlyList Items + ); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainRequest.cs new file mode 100644 index 0000000..737037b --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainRequest.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain; + +public sealed class MapDomainRequest +{ + public string TenantKey { get; set; } = default!; + public string Domain { get; set; } = default!; + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainResponse.cs new file mode 100644 index 0000000..a393bfa --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/MapDomain/MapDomainResponse.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.MapDomain; + +public sealed record MapDomainResponse( + string Domain, + string TenantKey + ); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainRequest.cs new file mode 100644 index 0000000..ebf39a2 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainRequest.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain; + +public sealed class RemoveBaseDomainRequest +{ + public string TenantKey { get; set; } = default!; + public string BaseDomain { get; set; } = default!; + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainResponse.cs new file mode 100644 index 0000000..cee8ee8 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/RemoveBaseDomain/RemoveBaseDomainResponse.cs @@ -0,0 +1,5 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.RemoveBaseDomain; + +public sealed record RemoveBaseDomainResponse( + string BaseDomain + ); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainRequest.cs new file mode 100644 index 0000000..b2e12d2 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainRequest.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain; + +public sealed class UnmapDomainRequest +{ + public string TenantKey { get; set; } = default!; + public string Domain { get; set; } = default!; + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainResponse.cs new file mode 100644 index 0000000..42a65eb --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/UnmapDomain/UnmapDomainResponse.cs @@ -0,0 +1,5 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.UnmapDomain; + +public sealed record UnmapDomainResponse( + string Domain + ); \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantRequest.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantRequest.cs new file mode 100644 index 0000000..9d9eca6 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantRequest.cs @@ -0,0 +1,14 @@ +using AMREZ.EOP.Domain.Shared.Tenancy; + +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant; + +public sealed class UpdateTenantRequest +{ + public string TenantKey { get; set; } = default!; + public string? Schema { get; set; } + public string? ConnectionString { get; set; } + public TenantMode? Mode { get; set; } + public bool? IsActive { get; set; } + public DateTimeOffset? IfUnmodifiedSince { get; set; } + public string? Tenant { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantResponse.cs b/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantResponse.cs new file mode 100644 index 0000000..325ee13 --- /dev/null +++ b/AMREZ.EOP.Contracts/DTOs/Tenancy/UpdateTenant/UpdateTenantResponse.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Contracts.DTOs.Tenancy.UpdateTenant; + +public sealed record UpdateTenantResponse( + string TenantKey, + DateTimeOffset UpdatedAtUtc + ); \ No newline at end of file diff --git a/AMREZ.EOP.Domain/AMREZ.EOP.Domain.csproj b/AMREZ.EOP.Domain/AMREZ.EOP.Domain.csproj new file mode 100644 index 0000000..52b6ef7 --- /dev/null +++ b/AMREZ.EOP.Domain/AMREZ.EOP.Domain.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/AMREZ.EOP.Domain/Entities/Authentications/Permission.cs b/AMREZ.EOP.Domain/Entities/Authentications/Permission.cs new file mode 100644 index 0000000..8e0b922 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/Permission.cs @@ -0,0 +1,12 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class Permission : BaseEntity +{ + public Guid TenantId { get; set; } + public string Code { get; set; } = default!; // e.g. "auth:session:read" + public string Name { get; set; } = default!; + + public ICollection RolePermissions { get; set; } = new List(); +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/Role.cs b/AMREZ.EOP.Domain/Entities/Authentications/Role.cs new file mode 100644 index 0000000..bad792d --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/Role.cs @@ -0,0 +1,13 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class Role : BaseEntity +{ + public Guid TenantId { get; set; } + public string Code { get; set; } = default!; // system code, unique per tenant + public string Name { get; set; } = default!; + + public ICollection UserRoles { get; set; } = new List(); + public ICollection RolePermissions { get; set; } = new List(); +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/RolePermission.cs b/AMREZ.EOP.Domain/Entities/Authentications/RolePermission.cs new file mode 100644 index 0000000..3580fec --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/RolePermission.cs @@ -0,0 +1,13 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class RolePermission : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid RoleId { get; set; } + public Guid PermissionId { get; set; } + + public Role Role { get; set; } = default!; + public Permission Permission { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/User.cs b/AMREZ.EOP.Domain/Entities/Authentications/User.cs new file mode 100644 index 0000000..9a77933 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/User.cs @@ -0,0 +1,24 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class User : BaseEntity +{ + public Guid TenantId { get; set; } + + public string PasswordHash { get; set; } = default!; + public bool IsActive { get; set; } = true; + + public int AccessFailedCount { get; set; } + public DateTimeOffset? LockoutEndUtc { get; set; } + public bool MfaEnabled { get; set; } + public string? SecurityStamp { get; set; } + + public ICollection Identities { get; set; } = new List(); + public ICollection MfaFactors { get; set; } = new List(); + public ICollection Sessions { get; set; } = new List(); + public ICollection PasswordHistories { get; set; } = new List(); + public ICollection ExternalAccounts { get; set; } = new List(); + + public ICollection UserRoles { get; set; } = new List(); +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserExternalAccount.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserExternalAccount.cs new file mode 100644 index 0000000..fab642c --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/UserExternalAccount.cs @@ -0,0 +1,17 @@ +using AMREZ.EOP.Domain.Entities.Common; +using AMREZ.EOP.Domain.Shared._Users; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class UserExternalAccount : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + + public ExternalProvider Provider { get; set; } + public string Subject { get; set; } = default!; // provider UID/sub + public string? Email { get; set; } + public DateTimeOffset LinkedAt { get; set; } = DateTimeOffset.UtcNow; + + public User User { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserIdentity.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserIdentity.cs new file mode 100644 index 0000000..9e1e90a --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/UserIdentity.cs @@ -0,0 +1,17 @@ +using AMREZ.EOP.Domain.Entities.Common; +using AMREZ.EOP.Domain.Shared._Users; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class UserIdentity : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + + public IdentityType Type { get; set; } + public string Identifier { get; set; } = default!; + public bool IsPrimary { get; set; } + public DateTimeOffset? VerifiedAt { get; set; } + + public User User { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserMfaFactor.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserMfaFactor.cs new file mode 100644 index 0000000..15de337 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/UserMfaFactor.cs @@ -0,0 +1,25 @@ +using AMREZ.EOP.Domain.Entities.Common; +using AMREZ.EOP.Domain.Shared._Users; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class UserMfaFactor : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + + public MfaType Type { get; set; } + public string? Label { get; set; } + + public string? Secret { get; set; } // TOTP secret (encrypt at rest) + public string? PhoneE164 { get; set; } + public string? Email { get; set; } + public string? PublicKey { get; set; } // WebAuthn + public string? CredentialId { get; set; } // WebAuthn + + public bool Enabled { get; set; } = true; + public DateTimeOffset AddedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastUsedAt { get; set; } + + public User User { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserPasswordHistory.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserPasswordHistory.cs new file mode 100644 index 0000000..bba1b73 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/UserPasswordHistory.cs @@ -0,0 +1,14 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class UserPasswordHistory : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + + public string PasswordHash { get; set; } = default!; + public DateTimeOffset ChangedAt { get; set; } = DateTimeOffset.UtcNow; + + public User User { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserRole.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserRole.cs new file mode 100644 index 0000000..696ef27 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/UserRole.cs @@ -0,0 +1,13 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class UserRole : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + public Guid RoleId { get; set; } + + public User User { get; set; } = default!; + public Role Role { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Authentications/UserSession.cs b/AMREZ.EOP.Domain/Entities/Authentications/UserSession.cs new file mode 100644 index 0000000..0c75e9d --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Authentications/UserSession.cs @@ -0,0 +1,20 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.Authentications; + +public sealed class UserSession : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + + public string RefreshTokenHash { get; set; } = default!; + public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public string? DeviceId { get; set; } + public string? UserAgent { get; set; } + public string? IpAddress { get; set; } + + public User User { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Common/BaseEntity.cs b/AMREZ.EOP.Domain/Entities/Common/BaseEntity.cs new file mode 100644 index 0000000..e65932d --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Common/BaseEntity.cs @@ -0,0 +1,13 @@ +namespace AMREZ.EOP.Domain.Entities.Common; + +public abstract class BaseEntity +{ + public Guid Id { get; set; } + public string TenantId { get; set; } = default!; + + public DateTimeOffset CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTimeOffset?UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + public bool IsDeleted { get; set; } = false; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/Department.cs b/AMREZ.EOP.Domain/Entities/HumanResources/Department.cs new file mode 100644 index 0000000..3665516 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/HumanResources/Department.cs @@ -0,0 +1,16 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.HumanResources; + +public sealed class Department : BaseEntity +{ + public Guid TenantId { get; set; } + public string Code { get; set; } = default!; + public string Name { get; set; } = default!; + + public Guid? ParentDepartmentId { get; set; } + public Department? Parent { get; set; } + + public ICollection Children { get; set; } = new List(); + public ICollection Profiles { get; set; } = new List(); +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/EmergencyContact.cs b/AMREZ.EOP.Domain/Entities/HumanResources/EmergencyContact.cs new file mode 100644 index 0000000..0dfcfae --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/HumanResources/EmergencyContact.cs @@ -0,0 +1,18 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.HumanResources; + +public sealed class EmergencyContact : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserProfileId { get; set; } + + public string Name { get; set; } = default!; + public string Relationship { get; set; } = default!; + public string? Phone { get; set; } + public string? Email { get; set; } + + public bool IsPrimary { get; set; } + + public UserProfile UserProfile { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeAddress.cs b/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeAddress.cs new file mode 100644 index 0000000..9e609d5 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeAddress.cs @@ -0,0 +1,23 @@ +using AMREZ.EOP.Domain.Entities.Common; +using AMREZ.EOP.Domain.Shared.HumanResources; + +namespace AMREZ.EOP.Domain.Entities.HumanResources; + +public sealed class EmployeeAddress : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserProfileId { get; set; } + + public AddressType Type { get; set; } = AddressType.Home; + + public string Line1 { get; set; } = default!; + public string? Line2 { get; set; } + public string City { get; set; } = default!; + public string? State { get; set; } + public string PostalCode { get; set; } = default!; + public string Country { get; set; } = default!; + + public bool IsPrimary { get; set; } + + public UserProfile UserProfile { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeBankAccount.cs b/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeBankAccount.cs new file mode 100644 index 0000000..0718853 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/HumanResources/EmployeeBankAccount.cs @@ -0,0 +1,18 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.HumanResources; + +public sealed class EmployeeBankAccount : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserProfileId { get; set; } + + public string BankName { get; set; } = default!; + public string AccountNumber { get; set; } = default!; + public string AccountHolder { get; set; } = default!; + public string? Branch { get; set; } + public string? Note { get; set; } + public bool IsPrimary { get; set; } + + public UserProfile UserProfile { get; set; } = default!; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/Employment.cs b/AMREZ.EOP.Domain/Entities/HumanResources/Employment.cs new file mode 100644 index 0000000..0013355 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/HumanResources/Employment.cs @@ -0,0 +1,25 @@ +using AMREZ.EOP.Domain.Entities.Common; +using AMREZ.EOP.Domain.Shared.HumanResources; + +namespace AMREZ.EOP.Domain.Entities.HumanResources; + +public sealed class Employment : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserProfileId { get; set; } + + public EmploymentType EmploymentType { get; set; } = EmploymentType.Permanent; + public DateTime StartDate { get; set; } + public DateTime? EndDate { get; set; } + + public Guid? DepartmentId { get; set; } + public Guid? PositionId { get; set; } + public Guid? ManagerUserId { get; set; } // references Authentications.User + + public string? WorkEmail { get; set; } + public string? WorkPhone { get; set; } + + public UserProfile UserProfile { get; set; } = default!; + public Department? Department { get; set; } + public Position? Position { get; set; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/Position.cs b/AMREZ.EOP.Domain/Entities/HumanResources/Position.cs new file mode 100644 index 0000000..c363726 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/HumanResources/Position.cs @@ -0,0 +1,13 @@ +using AMREZ.EOP.Domain.Entities.Common; + +namespace AMREZ.EOP.Domain.Entities.HumanResources; + +public sealed class Position : BaseEntity +{ + public Guid TenantId { get; set; } + public string Code { get; set; } = default!; + public string Title { get; set; } = default!; + public int? Level { get; set; } + + public ICollection Profiles { get; set; } = new List(); +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/HumanResources/UserProfile.cs b/AMREZ.EOP.Domain/Entities/HumanResources/UserProfile.cs new file mode 100644 index 0000000..0e03428 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/HumanResources/UserProfile.cs @@ -0,0 +1,26 @@ +using AMREZ.EOP.Domain.Entities.Authentications; +using AMREZ.EOP.Domain.Entities.Common; +using AMREZ.EOP.Domain.Shared.HumanResources; + +namespace AMREZ.EOP.Domain.Entities.HumanResources; + +public sealed class UserProfile : BaseEntity +{ + public Guid TenantId { get; set; } + public Guid UserId { get; set; } + + public string FirstName { get; set; } = default!; + public string LastName { get; set; } = default!; + public string? MiddleName { get; set; } + public string? Nickname { get; set; } + + public DateTime? DateOfBirth { get; set; } + public Gender? Gender { get; set; } + + public User User { get; set; } = default!; + + public ICollection Employments { get; set; } = new List(); + public ICollection Addresses { get; set; } = new List(); + public ICollection EmergencyContacts { get; set; } = new List(); + public ICollection BankAccounts { get; set; } = new List(); +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Tenancy/TenantConfig.cs b/AMREZ.EOP.Domain/Entities/Tenancy/TenantConfig.cs new file mode 100644 index 0000000..06d2c9c --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Tenancy/TenantConfig.cs @@ -0,0 +1,13 @@ +using AMREZ.EOP.Domain.Shared.Tenancy; + +namespace AMREZ.EOP.Domain.Entities.Tenancy; + +public sealed class TenantConfig +{ + public string TenantKey { get; set; } = default!; + public string? Schema { get; set; } + public string? ConnectionString { get; set; } + public TenantMode Mode { get; set; } = TenantMode.Rls; + public bool IsActive { get; set; } = true; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Entities/Tenancy/TenantDomain.cs b/AMREZ.EOP.Domain/Entities/Tenancy/TenantDomain.cs new file mode 100644 index 0000000..b6ffa59 --- /dev/null +++ b/AMREZ.EOP.Domain/Entities/Tenancy/TenantDomain.cs @@ -0,0 +1,10 @@ +namespace AMREZ.EOP.Domain.Entities.Tenancy; + +public class TenantDomain +{ + public string Domain { get; set; } = default!; + public string? TenantKey { get; set; } + public bool IsPlatformBaseDomain { get; set; } = false; + public bool IsActive { get; set; } = true; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/Contracts/AuthPolicies.cs b/AMREZ.EOP.Domain/Shared/Contracts/AuthPolicies.cs new file mode 100644 index 0000000..fb39969 --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/Contracts/AuthPolicies.cs @@ -0,0 +1,6 @@ +namespace AMREZ.EOP.Domain.Shared.Contracts; + +public static class AuthPolicies +{ + public const string Scheme = "AuthCookie"; +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/HumanResources/AddressType.cs b/AMREZ.EOP.Domain/Shared/HumanResources/AddressType.cs new file mode 100644 index 0000000..cbcf8f4 --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/HumanResources/AddressType.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Domain.Shared.HumanResources; + +public enum AddressType +{ + Home = 1, + Mailing = 2, + Other = 9 +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/HumanResources/EmploymentType.cs b/AMREZ.EOP.Domain/Shared/HumanResources/EmploymentType.cs new file mode 100644 index 0000000..de95d0d --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/HumanResources/EmploymentType.cs @@ -0,0 +1,9 @@ +namespace AMREZ.EOP.Domain.Shared.HumanResources; + +public enum EmploymentType +{ + Permanent = 1, + Contract = 2, + PartTime = 3, + Intern = 4 +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/HumanResources/Gender.cs b/AMREZ.EOP.Domain/Shared/HumanResources/Gender.cs new file mode 100644 index 0000000..4429a0d --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/HumanResources/Gender.cs @@ -0,0 +1,9 @@ +namespace AMREZ.EOP.Domain.Shared.HumanResources; + +public enum Gender +{ + Unknown = 0, + Male = 1, + Female = 2, + Other = 3 +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/Tenancy/HttpContextTenantExtensions.cs b/AMREZ.EOP.Domain/Shared/Tenancy/HttpContextTenantExtensions.cs new file mode 100644 index 0000000..91f91aa --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/Tenancy/HttpContextTenantExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Http; + +namespace AMREZ.EOP.Domain.Shared.Tenancy; + +public static class HttpContextTenantExtensions +{ + public static string? GetTargetTenantKey(this HttpContext http) + { + return http.Items.TryGetValue("TargetTenantKey", out var v) + ? v as string + : http.Request.Headers["X-Tenant"].ToString()?.Trim().ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/Tenancy/TenantMode.cs b/AMREZ.EOP.Domain/Shared/Tenancy/TenantMode.cs new file mode 100644 index 0000000..27bb3d9 --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/Tenancy/TenantMode.cs @@ -0,0 +1,8 @@ +namespace AMREZ.EOP.Domain.Shared.Tenancy; + +public enum TenantMode +{ + Rls, + Schema, + Database +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/๊Users/ExternalProvider.cs b/AMREZ.EOP.Domain/Shared/๊Users/ExternalProvider.cs new file mode 100644 index 0000000..efe8d1b --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/๊Users/ExternalProvider.cs @@ -0,0 +1,11 @@ +namespace AMREZ.EOP.Domain.Shared._Users; + +public enum ExternalProvider +{ + Google = 1, + Apple = 2, + Microsoft = 3, + Facebook = 4, + Line = 5, + Other = 9 +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/๊Users/IdentityType.cs b/AMREZ.EOP.Domain/Shared/๊Users/IdentityType.cs new file mode 100644 index 0000000..314ce68 --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/๊Users/IdentityType.cs @@ -0,0 +1,9 @@ +namespace AMREZ.EOP.Domain.Shared._Users; + +public enum IdentityType +{ + Email = 1, + Phone = 2, + Username = 3, + Other = 9 +} \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/๊Users/MfaType.cs b/AMREZ.EOP.Domain/Shared/๊Users/MfaType.cs new file mode 100644 index 0000000..189a4ed --- /dev/null +++ b/AMREZ.EOP.Domain/Shared/๊Users/MfaType.cs @@ -0,0 +1,9 @@ +namespace AMREZ.EOP.Domain.Shared._Users; + +public enum MfaType +{ + Totp = 1, + Sms = 2, + EmailOtp = 3, + WebAuthn = 4 +} diff --git a/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj b/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj new file mode 100644 index 0000000..14593c7 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/AMREZ.EOP.Infrastructures/Data/AppDbContext.cs b/AMREZ.EOP.Infrastructures/Data/AppDbContext.cs new file mode 100644 index 0000000..85bb717 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Data/AppDbContext.cs @@ -0,0 +1,365 @@ +using AMREZ.EOP.Domain.Entities.Authentications; +using AMREZ.EOP.Domain.Entities.Common; +using AMREZ.EOP.Domain.Entities.HumanResources; +using AMREZ.EOP.Domain.Entities.Tenancy; +using Microsoft.EntityFrameworkCore; + +namespace AMREZ.EOP.Infrastructures.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + // ===== Auth ===== + public DbSet Users => Set(); + public DbSet UserIdentities => Set(); + public DbSet UserMfaFactors => Set(); + public DbSet UserSessions => Set(); + public DbSet UserPasswordHistories => Set(); + public DbSet UserExternalAccounts => Set(); + public DbSet Roles => Set(); + public DbSet Permissions => Set(); + public DbSet UserRoles => Set(); + public DbSet RolePermissions => Set(); + + // ===== HR ===== + public DbSet UserProfiles => Set(); + public DbSet Departments => Set(); + public DbSet Positions => Set(); + public DbSet Employments => Set(); + public DbSet EmployeeAddresses => Set(); + public DbSet EmergencyContacts => Set(); + public DbSet EmployeeBankAccounts => Set(); + + // ===== Tenancy (meta) ===== + public DbSet Tenants => Set(); + public DbSet TenantDomains => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + // ====== Global Tenancy Config (meta schema) — ไม่สืบทอด BaseEntity ====== + model.Entity(b => + { + b.ToTable("tenants", schema: "meta"); + b.HasKey(x => x.TenantKey); + b.Property(x => x.TenantKey).HasMaxLength(128).IsRequired(); + b.Property(x => x.Schema).HasMaxLength(128); + b.Property(x => x.ConnectionString); + b.Property(x => x.Mode).IsRequired(); + b.Property(x => x.IsActive).HasDefaultValue(true); + b.Property(x => x.UpdatedAtUtc) + .HasColumnName("updated_at_utc") + .HasDefaultValueSql("now() at time zone 'utc'"); + b.HasIndex(x => x.IsActive); + }); + + model.Entity(b => + { + b.ToTable("tenant_domains", schema: "meta"); + b.HasKey(x => x.Domain); + b.Property(x => x.Domain).HasMaxLength(253).IsRequired(); + b.Property(x => x.TenantKey).HasMaxLength(128); + b.Property(x => x.IsPlatformBaseDomain).HasDefaultValue(false); + b.Property(x => x.IsActive).HasDefaultValue(true); + b.Property(x => x.UpdatedAtUtc) + .HasColumnName("updated_at_utc") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasIndex(x => x.TenantKey); + b.HasIndex(x => x.IsPlatformBaseDomain); + b.HasIndex(x => x.IsActive); + + b.HasOne() + .WithMany() + .HasForeignKey(x => x.TenantKey) + .OnDelete(DeleteBehavior.Cascade); + }); + + // ====== Auth ====== + model.Entity(b => + { + b.ToTable("users"); + b.HasKey(x => x.Id); + + b.Property(x => x.PasswordHash).IsRequired(); + b.Property(x => x.IsActive).HasDefaultValue(true); + + b.Property(x => x.AccessFailedCount).HasDefaultValue(0); + b.Property(x => x.MfaEnabled).HasDefaultValue(false); + + b.HasMany(x => x.Identities) + .WithOne(i => i.User) + .HasForeignKey(i => i.UserId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasMany(x => x.MfaFactors) + .WithOne(i => i.User) + .HasForeignKey(i => i.UserId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasMany(x => x.Sessions) + .WithOne(s => s.User) + .HasForeignKey(s => s.UserId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasMany(x => x.PasswordHistories) + .WithOne(ph => ph.User) + .HasForeignKey(ph => ph.UserId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasMany(x => x.ExternalAccounts) + .WithOne(ea => ea.User) + .HasForeignKey(ea => ea.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + model.Entity(b => + { + b.ToTable("user_identities"); + b.HasKey(x => x.Id); + + b.Property(x => x.Type).IsRequired(); + b.Property(x => x.Identifier).IsRequired().HasMaxLength(256); + b.Property(x => x.IsPrimary).HasDefaultValue(false); + + b.HasIndex(x => new { x.TenantId, x.Type, x.Identifier }) + .IsUnique(); + + b.HasIndex(x => new { x.TenantId, x.UserId, x.Type, x.IsPrimary }) + .HasDatabaseName("ix_user_identity_primary_per_type"); + }); + + model.Entity(b => + { + b.ToTable("user_mfa_factors"); + b.HasKey(x => x.Id); + + b.Property(x => x.Type).IsRequired(); + b.Property(x => x.Enabled).HasDefaultValue(true); + + b.HasIndex(x => new { x.TenantId, x.UserId }); + }); + + model.Entity(b => + { + b.ToTable("user_sessions"); + b.HasKey(x => x.Id); + + b.Property(x => x.RefreshTokenHash).IsRequired(); + b.HasIndex(x => new { x.TenantId, x.UserId }); + b.HasIndex(x => new { x.TenantId, x.DeviceId }); + }); + + model.Entity(b => + { + b.ToTable("user_password_histories"); + b.HasKey(x => x.Id); + b.Property(x => x.PasswordHash).IsRequired(); + b.HasIndex(x => new { x.TenantId, x.UserId, x.ChangedAt }); + }); + + model.Entity(b => + { + b.ToTable("user_external_accounts"); + b.HasKey(x => x.Id); + + b.Property(x => x.Provider).IsRequired(); + b.Property(x => x.Subject).IsRequired(); + + b.HasIndex(x => new { x.TenantId, x.Provider, x.Subject }) + .IsUnique(); + }); + + model.Entity(b => + { + b.ToTable("roles"); + b.HasKey(x => x.Id); + + b.Property(x => x.Code).IsRequired().HasMaxLength(128); + b.Property(x => x.Name).IsRequired().HasMaxLength(256); + + b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + }); + + model.Entity(b => + { + b.ToTable("permissions"); + b.HasKey(x => x.Id); + + b.Property(x => x.Code).IsRequired().HasMaxLength(256); + b.Property(x => x.Name).IsRequired().HasMaxLength(256); + + b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + }); + + model.Entity(b => + { + b.ToTable("user_roles"); + b.HasKey(x => x.Id); + + b.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique(); + }); + + model.Entity(b => + { + b.ToTable("role_permissions"); + b.HasKey(x => x.Id); + + b.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique(); + }); + + // ====== HR ====== + model.Entity(b => + { + b.ToTable("user_profiles"); + b.HasKey(x => x.Id); + + b.Property(x => x.FirstName).IsRequired().HasMaxLength(128); + b.Property(x => x.LastName).IsRequired().HasMaxLength(128); + + b.HasOne(x => x.User) + .WithOne() + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasIndex(x => new { x.TenantId, x.UserId }).IsUnique(); + }); + + model.Entity(b => + { + b.ToTable("departments"); + b.HasKey(x => x.Id); + + b.Property(x => x.Code).IsRequired().HasMaxLength(64); + b.Property(x => x.Name).IsRequired().HasMaxLength(256); + + b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + + b.HasOne(x => x.Parent) + .WithMany(x => x.Children) + .HasForeignKey(x => x.ParentDepartmentId) + .OnDelete(DeleteBehavior.Restrict); + }); + + model.Entity(b => + { + b.ToTable("positions"); + b.HasKey(x => x.Id); + + b.Property(x => x.Code).IsRequired().HasMaxLength(64); + b.Property(x => x.Title).IsRequired().HasMaxLength(256); + + b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + }); + + model.Entity(b => + { + b.ToTable("employments"); + b.HasKey(x => x.Id); + + b.Property(x => x.EmploymentType).IsRequired(); + b.Property(x => x.StartDate).IsRequired(); + + b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.StartDate }); + + b.HasOne(x => x.UserProfile) + .WithMany(p => p.Employments) + .HasForeignKey(x => x.UserProfileId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne(x => x.Department) + .WithMany() + .HasForeignKey(x => x.DepartmentId) + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne(x => x.Position) + .WithMany() + .HasForeignKey(x => x.PositionId) + .OnDelete(DeleteBehavior.Restrict); + }); + + model.Entity(b => + { + b.ToTable("employee_addresses"); + b.HasKey(x => x.Id); + + b.Property(x => x.Line1).IsRequired().HasMaxLength(256); + b.Property(x => x.City).IsRequired().HasMaxLength(128); + b.Property(x => x.PostalCode).IsRequired().HasMaxLength(32); + b.Property(x => x.Country).IsRequired().HasMaxLength(64); + + b.HasOne(x => x.UserProfile) + .WithMany(p => p.Addresses) + .HasForeignKey(x => x.UserProfileId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary }); + }); + + model.Entity(b => + { + b.ToTable("emergency_contacts"); + b.HasKey(x => x.Id); + + b.Property(x => x.Name).IsRequired().HasMaxLength(128); + b.Property(x => x.Relationship).IsRequired().HasMaxLength(64); + + b.HasOne(x => x.UserProfile) + .WithMany(p => p.EmergencyContacts) + .HasForeignKey(x => x.UserProfileId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary }); + }); + + model.Entity(b => + { + b.ToTable("employee_bank_accounts"); + b.HasKey(x => x.Id); + + b.Property(x => x.BankName).IsRequired().HasMaxLength(128); + b.Property(x => x.AccountNumber).IsRequired().HasMaxLength(64); + b.Property(x => x.AccountHolder).IsRequired().HasMaxLength(128); + + b.HasOne(x => x.UserProfile) + .WithMany(p => p.BankAccounts) + .HasForeignKey(x => x.UserProfileId) + .OnDelete(DeleteBehavior.Cascade); + + b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary }); + }); + + // ====== Enums as ints ====== + model.Entity().Property(x => x.Type).HasConversion(); + model.Entity().Property(x => x.Type).HasConversion(); + model.Entity().Property(x => x.Provider).HasConversion(); + model.Entity().Property(x => x.EmploymentType).HasConversion(); + model.Entity().Property(x => x.Gender).HasConversion(); + + // ====== BaseEntity common mapping ====== + foreach (var et in model.Model.GetEntityTypes() + .Where(t => typeof(BaseEntity).IsAssignableFrom(t.ClrType))) + { + var b = model.Entity(et.ClrType); + + // Tenant + b.Property(nameof(BaseEntity.TenantId)) + .HasColumnName("tenant_id") + .HasColumnType("uuid") + .IsRequired() + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.HasIndex(nameof(BaseEntity.TenantId)); + b.HasCheckConstraint($"ck_{et.GetTableName()}_tenant_not_null", "tenant_id is not null"); + + // Audit + b.Property("CreatedAt") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + b.Property("UpdatedAt").HasColumnName("updated_at"); + b.Property("CreatedBy").HasColumnName("created_by"); + b.Property("UpdatedBy").HasColumnName("updated_by"); + b.Property("IsDeleted").HasColumnName("is_deleted").HasDefaultValue(false); + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs b/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..4bde9a2 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs @@ -0,0 +1,137 @@ +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; +using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Abstractions.Security; +using AMREZ.EOP.Abstractions.Storage; +using AMREZ.EOP.Application.UseCases.Authentications; +using AMREZ.EOP.Application.UseCases.HumanResources; +using AMREZ.EOP.Application.UseCases.Tenancy; +using AMREZ.EOP.Domain.Shared.Tenancy; +using AMREZ.EOP.Infrastructures.Data; +using AMREZ.EOP.Infrastructures.Options; +using AMREZ.EOP.Infrastructures.Repositories; +using AMREZ.EOP.Infrastructures.Security; +using AMREZ.EOP.Infrastructures.Storage; +using AMREZ.EOP.Infrastructures.Tenancy; +using AMREZ.EOP.Infrastructures.UnitOfWork; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace AMREZ.EOP.Infrastructures.DependencyInjections; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + + // Options + services.AddOptions() + .BindConfiguration("Connections") + .ValidateOnStart(); + + // Tenancy core (order matters for AddDbContext factory resolution) + services.AddSingleton(sp => + { + var map = new TenantMap(); + var opts = sp.GetRequiredService>().Value; + map.UpsertTenant("public", new TenantContext + { + Id = "public", + Schema = "public", + ConnectionString = string.IsNullOrWhiteSpace(opts.DefaultConnection) ? null : opts.DefaultConnection, + Mode = TenantMode.Rls + }); + return map; + }); + services.AddHostedService(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // DbContext (attach RLS/search_path interceptors) + services.AddDbContext((sp, o) => + { + var cfg = sp.GetRequiredService>().Value; + o.UseNpgsql(cfg.DefaultConnection); + o.AddInterceptors( + sp.GetRequiredService(), + sp.GetRequiredService() + ); + }); + + // Db scope / UoW + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Security + services.AddScoped(); + + // Repositories — Auth & HR แยกกัน + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // UseCases — Authentication + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // UseCases — HR + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // UseCases — Tenancy + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Redis (optional) + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + var conn = opts.RedisConnection; + if (string.IsNullOrWhiteSpace(conn)) return null; + + try + { + var cfg = ConfigurationOptions.Parse(conn, true); + cfg.AbortOnConnectFail = false; + cfg.ConnectTimeout = 1000; + cfg.SyncTimeout = 1000; + var mux = ConnectionMultiplexer.Connect(cfg); + return mux.IsConnected ? mux : null; + } + catch { return null; } + }); + + return services; + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Migrations/20250924034125_InitDatabase.Designer.cs b/AMREZ.EOP.Infrastructures/Migrations/20250924034125_InitDatabase.Designer.cs new file mode 100644 index 0000000..91b5bc2 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Migrations/20250924034125_InitDatabase.Designer.cs @@ -0,0 +1,1536 @@ +// +using System; +using AMREZ.EOP.Infrastructures.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AMREZ.EOP.Infrastructures.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20250924034125_InitDatabase")] + partial class InitDatabase + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("permissions", null, t => + { + t.HasCheckConstraint("ck_permissions_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("roles", null, t => + { + t.HasCheckConstraint("ck_roles_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("PermissionId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("PermissionId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "PermissionId") + .IsUnique(); + + b.ToTable("role_permissions", null, t => + { + t.HasCheckConstraint("ck_role_permissions_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LockoutEndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MfaEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("ck_users_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserExternalAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "Provider", "Subject") + .IsUnique(); + + b.ToTable("user_external_accounts", null, t => + { + t.HasCheckConstraint("ck_user_external_accounts_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "Type", "Identifier") + .IsUnique(); + + b.HasIndex("TenantId", "UserId", "Type", "IsPrimary") + .HasDatabaseName("ix_user_identity_primary_per_type"); + + b.ToTable("user_identities", null, t => + { + t.HasCheckConstraint("ck_user_identities_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserMfaFactor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CredentialId") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Label") + .HasColumnType("text"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PhoneE164") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("Secret") + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("user_mfa_factors", null, t => + { + t.HasCheckConstraint("ck_user_mfa_factors_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserPasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "ChangedAt"); + + b.ToTable("user_password_histories", null, t => + { + t.HasCheckConstraint("ck_user_password_histories_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("user_roles", null, t => + { + t.HasCheckConstraint("ck_user_roles_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "DeviceId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("user_sessions", null, t => + { + t.HasCheckConstraint("ck_user_sessions_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ParentDepartmentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ParentDepartmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("departments", null, t => + { + t.HasCheckConstraint("ck_departments_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmergencyContact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Relationship") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserProfileId"); + + b.HasIndex("TenantId", "UserProfileId", "IsPrimary"); + + b.ToTable("emergency_contacts", null, t => + { + t.HasCheckConstraint("ck_emergency_contacts_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Line2") + .HasColumnType("text"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserProfileId"); + + b.HasIndex("TenantId", "UserProfileId", "IsPrimary"); + + b.ToTable("employee_addresses", null, t => + { + t.HasCheckConstraint("ck_employee_addresses_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmployeeBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountHolder") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("BankName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Branch") + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserProfileId"); + + b.HasIndex("TenantId", "UserProfileId", "IsPrimary"); + + b.ToTable("employee_bank_accounts", null, t => + { + t.HasCheckConstraint("ck_employee_bank_accounts_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Employment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("EmploymentType") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ManagerUserId") + .HasColumnType("uuid"); + + b.Property("PositionId") + .HasColumnType("uuid"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.Property("WorkEmail") + .HasColumnType("text"); + + b.Property("WorkPhone") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PositionId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserProfileId"); + + b.HasIndex("TenantId", "UserProfileId", "StartDate"); + + b.ToTable("employments", null, t => + { + t.HasCheckConstraint("ck_employments_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Position", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("positions", null, t => + { + t.HasCheckConstraint("ck_positions_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MiddleName") + .HasColumnType("text"); + + b.Property("Nickname") + .HasColumnType("text"); + + b.Property("PositionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PositionId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("TenantId", "UserId") + .IsUnique(); + + b.ToTable("user_profiles", null, t => + { + t.HasCheckConstraint("ck_user_profiles_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Tenancy.TenantConfig", b => + { + b.Property("TenantKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConnectionString") + .HasColumnType("text"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Schema") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("TenantKey"); + + b.HasIndex("IsActive"); + + b.ToTable("tenants", "meta"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Tenancy.TenantDomain", b => + { + b.Property("Domain") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsPlatformBaseDomain") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TenantKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Domain"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsPlatformBaseDomain"); + + b.HasIndex("TenantKey"); + + b.ToTable("tenant_domains", "meta"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.RolePermission", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.Permission", "Permission") + .WithMany("RolePermissions") + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserExternalAccount", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("ExternalAccounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserIdentity", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("Identities") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserMfaFactor", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("MfaFactors") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserPasswordHistory", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserRole", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserSession", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Department", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Department", "Parent") + .WithMany("Children") + .HasForeignKey("ParentDepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmergencyContact", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserProfile") + .WithMany("EmergencyContacts") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmployeeAddress", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserProfile") + .WithMany("Addresses") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmployeeBankAccount", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserProfile") + .WithMany("BankAccounts") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Employment", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Position", "Position") + .WithMany() + .HasForeignKey("PositionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserProfile") + .WithMany("Employments") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Department"); + + b.Navigation("Position"); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Department", null) + .WithMany("Profiles") + .HasForeignKey("DepartmentId"); + + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Position", null) + .WithMany("Profiles") + .HasForeignKey("PositionId"); + + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithOne() + .HasForeignKey("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Tenancy.TenantDomain", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Tenancy.TenantConfig", null) + .WithMany() + .HasForeignKey("TenantKey") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.Permission", b => + { + b.Navigation("RolePermissions"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.User", b => + { + b.Navigation("ExternalAccounts"); + + b.Navigation("Identities"); + + b.Navigation("MfaFactors"); + + b.Navigation("PasswordHistories"); + + b.Navigation("Sessions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Department", b => + { + b.Navigation("Children"); + + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Position", b => + { + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", b => + { + b.Navigation("Addresses"); + + b.Navigation("BankAccounts"); + + b.Navigation("EmergencyContacts"); + + b.Navigation("Employments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AMREZ.EOP.Infrastructures/Migrations/20250924034125_InitDatabase.cs b/AMREZ.EOP.Infrastructures/Migrations/20250924034125_InitDatabase.cs new file mode 100644 index 0000000..55239d2 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Migrations/20250924034125_InitDatabase.cs @@ -0,0 +1,935 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AMREZ.EOP.Infrastructures.Migrations +{ + /// + public partial class InitDatabase : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "meta"); + + migrationBuilder.CreateTable( + name: "departments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + Code = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ParentDepartmentId = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_departments", x => x.Id); + table.CheckConstraint("ck_departments_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_departments_departments_ParentDepartmentId", + column: x => x.ParentDepartmentId, + principalTable: "departments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "permissions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + Code = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_permissions", x => x.Id); + table.CheckConstraint("ck_permissions_tenant_not_null", "tenant_id is not null"); + }); + + migrationBuilder.CreateTable( + name: "positions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + Code = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Title = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Level = table.Column(type: "integer", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_positions", x => x.Id); + table.CheckConstraint("ck_positions_tenant_not_null", "tenant_id is not null"); + }); + + migrationBuilder.CreateTable( + name: "roles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + Code = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_roles", x => x.Id); + table.CheckConstraint("ck_roles_tenant_not_null", "tenant_id is not null"); + }); + + migrationBuilder.CreateTable( + name: "tenants", + schema: "meta", + columns: table => new + { + TenantKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Schema = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + ConnectionString = table.Column(type: "text", nullable: true), + Mode = table.Column(type: "integer", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false, defaultValue: true), + updated_at_utc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'") + }, + constraints: table => + { + table.PrimaryKey("PK_tenants", x => x.TenantKey); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + PasswordHash = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false, defaultValue: true), + AccessFailedCount = table.Column(type: "integer", nullable: false, defaultValue: 0), + LockoutEndUtc = table.Column(type: "timestamp with time zone", nullable: true), + MfaEnabled = table.Column(type: "boolean", nullable: false, defaultValue: false), + SecurityStamp = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.Id); + table.CheckConstraint("ck_users_tenant_not_null", "tenant_id is not null"); + }); + + migrationBuilder.CreateTable( + name: "role_permissions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + RoleId = table.Column(type: "uuid", nullable: false), + PermissionId = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_role_permissions", x => x.Id); + table.CheckConstraint("ck_role_permissions_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_role_permissions_permissions_PermissionId", + column: x => x.PermissionId, + principalTable: "permissions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_role_permissions_roles_RoleId", + column: x => x.RoleId, + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "tenant_domains", + schema: "meta", + columns: table => new + { + Domain = table.Column(type: "character varying(253)", maxLength: 253, nullable: false), + TenantKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + IsPlatformBaseDomain = table.Column(type: "boolean", nullable: false, defaultValue: false), + IsActive = table.Column(type: "boolean", nullable: false, defaultValue: true), + updated_at_utc = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'") + }, + constraints: table => + { + table.PrimaryKey("PK_tenant_domains", x => x.Domain); + table.ForeignKey( + name: "FK_tenant_domains_tenants_TenantKey", + column: x => x.TenantKey, + principalSchema: "meta", + principalTable: "tenants", + principalColumn: "TenantKey", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_external_accounts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserId = table.Column(type: "uuid", nullable: false), + Provider = table.Column(type: "integer", nullable: false), + Subject = table.Column(type: "text", nullable: false), + Email = table.Column(type: "text", nullable: true), + LinkedAt = table.Column(type: "timestamp with time zone", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_external_accounts", x => x.Id); + table.CheckConstraint("ck_user_external_accounts_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_user_external_accounts_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_identities", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "integer", nullable: false), + Identifier = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + IsPrimary = table.Column(type: "boolean", nullable: false, defaultValue: false), + VerifiedAt = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_identities", x => x.Id); + table.CheckConstraint("ck_user_identities_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_user_identities_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_mfa_factors", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "integer", nullable: false), + Label = table.Column(type: "text", nullable: true), + Secret = table.Column(type: "text", nullable: true), + PhoneE164 = table.Column(type: "text", nullable: true), + Email = table.Column(type: "text", nullable: true), + PublicKey = table.Column(type: "text", nullable: true), + CredentialId = table.Column(type: "text", nullable: true), + Enabled = table.Column(type: "boolean", nullable: false, defaultValue: true), + AddedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastUsedAt = table.Column(type: "timestamp with time zone", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_mfa_factors", x => x.Id); + table.CheckConstraint("ck_user_mfa_factors_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_user_mfa_factors_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_password_histories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserId = table.Column(type: "uuid", nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + ChangedAt = table.Column(type: "timestamp with time zone", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_password_histories", x => x.Id); + table.CheckConstraint("ck_user_password_histories_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_user_password_histories_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_profiles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserId = table.Column(type: "uuid", nullable: false), + FirstName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + LastName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + MiddleName = table.Column(type: "text", nullable: true), + Nickname = table.Column(type: "text", nullable: true), + DateOfBirth = table.Column(type: "timestamp with time zone", nullable: true), + Gender = table.Column(type: "integer", nullable: true), + DepartmentId = table.Column(type: "uuid", nullable: true), + PositionId = table.Column(type: "uuid", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_profiles", x => x.Id); + table.CheckConstraint("ck_user_profiles_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_user_profiles_departments_DepartmentId", + column: x => x.DepartmentId, + principalTable: "departments", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_user_profiles_positions_PositionId", + column: x => x.PositionId, + principalTable: "positions", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_user_profiles_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_roles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserId = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_roles", x => x.Id); + table.CheckConstraint("ck_user_roles_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_user_roles_roles_RoleId", + column: x => x.RoleId, + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_user_roles_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_sessions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserId = table.Column(type: "uuid", nullable: false), + RefreshTokenHash = table.Column(type: "text", nullable: false), + IssuedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeviceId = table.Column(type: "text", nullable: true), + UserAgent = table.Column(type: "text", nullable: true), + IpAddress = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_sessions", x => x.Id); + table.CheckConstraint("ck_user_sessions_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_user_sessions_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "emergency_contacts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserProfileId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Relationship = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Phone = table.Column(type: "text", nullable: true), + Email = table.Column(type: "text", nullable: true), + IsPrimary = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_emergency_contacts", x => x.Id); + table.CheckConstraint("ck_emergency_contacts_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_emergency_contacts_user_profiles_UserProfileId", + column: x => x.UserProfileId, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "employee_addresses", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserProfileId = table.Column(type: "uuid", nullable: false), + Type = table.Column(type: "integer", nullable: false), + Line1 = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Line2 = table.Column(type: "text", nullable: true), + City = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + State = table.Column(type: "text", nullable: true), + PostalCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Country = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + IsPrimary = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_employee_addresses", x => x.Id); + table.CheckConstraint("ck_employee_addresses_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_employee_addresses_user_profiles_UserProfileId", + column: x => x.UserProfileId, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "employee_bank_accounts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserProfileId = table.Column(type: "uuid", nullable: false), + BankName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + AccountNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + AccountHolder = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Branch = table.Column(type: "text", nullable: true), + Note = table.Column(type: "text", nullable: true), + IsPrimary = table.Column(type: "boolean", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_employee_bank_accounts", x => x.Id); + table.CheckConstraint("ck_employee_bank_accounts_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_employee_bank_accounts_user_profiles_UserProfileId", + column: x => x.UserProfileId, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "employments", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: false, defaultValueSql: "nullif(current_setting('app.tenant_id', true),'')::uuid"), + UserProfileId = table.Column(type: "uuid", nullable: false), + EmploymentType = table.Column(type: "integer", nullable: false), + StartDate = table.Column(type: "timestamp with time zone", nullable: false), + EndDate = table.Column(type: "timestamp with time zone", nullable: true), + DepartmentId = table.Column(type: "uuid", nullable: true), + PositionId = table.Column(type: "uuid", nullable: true), + ManagerUserId = table.Column(type: "uuid", nullable: true), + WorkEmail = table.Column(type: "text", nullable: true), + WorkPhone = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"), + created_by = table.Column(type: "text", nullable: true), + updated_at = table.Column(type: "timestamp with time zone", nullable: true), + updated_by = table.Column(type: "text", nullable: true), + is_deleted = table.Column(type: "boolean", nullable: false, defaultValue: false) + }, + constraints: table => + { + table.PrimaryKey("PK_employments", x => x.Id); + table.CheckConstraint("ck_employments_tenant_not_null", "tenant_id is not null"); + table.ForeignKey( + name: "FK_employments_departments_DepartmentId", + column: x => x.DepartmentId, + principalTable: "departments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_employments_positions_PositionId", + column: x => x.PositionId, + principalTable: "positions", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_employments_user_profiles_UserProfileId", + column: x => x.UserProfileId, + principalTable: "user_profiles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_departments_ParentDepartmentId", + table: "departments", + column: "ParentDepartmentId"); + + migrationBuilder.CreateIndex( + name: "IX_departments_tenant_id", + table: "departments", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_departments_tenant_id_Code", + table: "departments", + columns: new[] { "tenant_id", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_emergency_contacts_tenant_id", + table: "emergency_contacts", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_emergency_contacts_tenant_id_UserProfileId_IsPrimary", + table: "emergency_contacts", + columns: new[] { "tenant_id", "UserProfileId", "IsPrimary" }); + + migrationBuilder.CreateIndex( + name: "IX_emergency_contacts_UserProfileId", + table: "emergency_contacts", + column: "UserProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_employee_addresses_tenant_id", + table: "employee_addresses", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_employee_addresses_tenant_id_UserProfileId_IsPrimary", + table: "employee_addresses", + columns: new[] { "tenant_id", "UserProfileId", "IsPrimary" }); + + migrationBuilder.CreateIndex( + name: "IX_employee_addresses_UserProfileId", + table: "employee_addresses", + column: "UserProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_employee_bank_accounts_tenant_id", + table: "employee_bank_accounts", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_employee_bank_accounts_tenant_id_UserProfileId_IsPrimary", + table: "employee_bank_accounts", + columns: new[] { "tenant_id", "UserProfileId", "IsPrimary" }); + + migrationBuilder.CreateIndex( + name: "IX_employee_bank_accounts_UserProfileId", + table: "employee_bank_accounts", + column: "UserProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_employments_DepartmentId", + table: "employments", + column: "DepartmentId"); + + migrationBuilder.CreateIndex( + name: "IX_employments_PositionId", + table: "employments", + column: "PositionId"); + + migrationBuilder.CreateIndex( + name: "IX_employments_tenant_id", + table: "employments", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_employments_tenant_id_UserProfileId_StartDate", + table: "employments", + columns: new[] { "tenant_id", "UserProfileId", "StartDate" }); + + migrationBuilder.CreateIndex( + name: "IX_employments_UserProfileId", + table: "employments", + column: "UserProfileId"); + + migrationBuilder.CreateIndex( + name: "IX_permissions_tenant_id", + table: "permissions", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_permissions_tenant_id_Code", + table: "permissions", + columns: new[] { "tenant_id", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_positions_tenant_id", + table: "positions", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_positions_tenant_id_Code", + table: "positions", + columns: new[] { "tenant_id", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_PermissionId", + table: "role_permissions", + column: "PermissionId"); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_RoleId", + table: "role_permissions", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_tenant_id", + table: "role_permissions", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_tenant_id_RoleId_PermissionId", + table: "role_permissions", + columns: new[] { "tenant_id", "RoleId", "PermissionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_roles_tenant_id", + table: "roles", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_roles_tenant_id_Code", + table: "roles", + columns: new[] { "tenant_id", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_tenant_domains_IsActive", + schema: "meta", + table: "tenant_domains", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_tenant_domains_IsPlatformBaseDomain", + schema: "meta", + table: "tenant_domains", + column: "IsPlatformBaseDomain"); + + migrationBuilder.CreateIndex( + name: "IX_tenant_domains_TenantKey", + schema: "meta", + table: "tenant_domains", + column: "TenantKey"); + + migrationBuilder.CreateIndex( + name: "IX_tenants_IsActive", + schema: "meta", + table: "tenants", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_user_external_accounts_tenant_id", + table: "user_external_accounts", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_external_accounts_tenant_id_Provider_Subject", + table: "user_external_accounts", + columns: new[] { "tenant_id", "Provider", "Subject" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_external_accounts_UserId", + table: "user_external_accounts", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_user_identities_tenant_id", + table: "user_identities", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_identities_tenant_id_Type_Identifier", + table: "user_identities", + columns: new[] { "tenant_id", "Type", "Identifier" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_identities_UserId", + table: "user_identities", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "ix_user_identity_primary_per_type", + table: "user_identities", + columns: new[] { "tenant_id", "UserId", "Type", "IsPrimary" }); + + migrationBuilder.CreateIndex( + name: "IX_user_mfa_factors_tenant_id", + table: "user_mfa_factors", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_mfa_factors_tenant_id_UserId", + table: "user_mfa_factors", + columns: new[] { "tenant_id", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_user_mfa_factors_UserId", + table: "user_mfa_factors", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_user_password_histories_tenant_id", + table: "user_password_histories", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_password_histories_tenant_id_UserId_ChangedAt", + table: "user_password_histories", + columns: new[] { "tenant_id", "UserId", "ChangedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_user_password_histories_UserId", + table: "user_password_histories", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_DepartmentId", + table: "user_profiles", + column: "DepartmentId"); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_PositionId", + table: "user_profiles", + column: "PositionId"); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_tenant_id", + table: "user_profiles", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_tenant_id_UserId", + table: "user_profiles", + columns: new[] { "tenant_id", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_UserId", + table: "user_profiles", + column: "UserId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_roles_RoleId", + table: "user_roles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_user_roles_tenant_id", + table: "user_roles", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_roles_tenant_id_UserId_RoleId", + table: "user_roles", + columns: new[] { "tenant_id", "UserId", "RoleId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_roles_UserId", + table: "user_roles", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_user_sessions_tenant_id", + table: "user_sessions", + column: "tenant_id"); + + migrationBuilder.CreateIndex( + name: "IX_user_sessions_tenant_id_DeviceId", + table: "user_sessions", + columns: new[] { "tenant_id", "DeviceId" }); + + migrationBuilder.CreateIndex( + name: "IX_user_sessions_tenant_id_UserId", + table: "user_sessions", + columns: new[] { "tenant_id", "UserId" }); + + migrationBuilder.CreateIndex( + name: "IX_user_sessions_UserId", + table: "user_sessions", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_users_tenant_id", + table: "users", + column: "tenant_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "emergency_contacts"); + + migrationBuilder.DropTable( + name: "employee_addresses"); + + migrationBuilder.DropTable( + name: "employee_bank_accounts"); + + migrationBuilder.DropTable( + name: "employments"); + + migrationBuilder.DropTable( + name: "role_permissions"); + + migrationBuilder.DropTable( + name: "tenant_domains", + schema: "meta"); + + migrationBuilder.DropTable( + name: "user_external_accounts"); + + migrationBuilder.DropTable( + name: "user_identities"); + + migrationBuilder.DropTable( + name: "user_mfa_factors"); + + migrationBuilder.DropTable( + name: "user_password_histories"); + + migrationBuilder.DropTable( + name: "user_roles"); + + migrationBuilder.DropTable( + name: "user_sessions"); + + migrationBuilder.DropTable( + name: "user_profiles"); + + migrationBuilder.DropTable( + name: "permissions"); + + migrationBuilder.DropTable( + name: "tenants", + schema: "meta"); + + migrationBuilder.DropTable( + name: "roles"); + + migrationBuilder.DropTable( + name: "departments"); + + migrationBuilder.DropTable( + name: "positions"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/AMREZ.EOP.Infrastructures/Migrations/AppDbContextModelSnapshot.cs b/AMREZ.EOP.Infrastructures/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..b2251a8 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,1533 @@ +// +using System; +using AMREZ.EOP.Infrastructures.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace AMREZ.EOP.Infrastructures.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("permissions", null, t => + { + t.HasCheckConstraint("ck_permissions_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("roles", null, t => + { + t.HasCheckConstraint("ck_roles_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("PermissionId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("PermissionId"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "PermissionId") + .IsUnique(); + + b.ToTable("role_permissions", null, t => + { + t.HasCheckConstraint("ck_role_permissions_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LockoutEndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MfaEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("ck_users_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserExternalAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "Provider", "Subject") + .IsUnique(); + + b.ToTable("user_external_accounts", null, t => + { + t.HasCheckConstraint("ck_user_external_accounts_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "Type", "Identifier") + .IsUnique(); + + b.HasIndex("TenantId", "UserId", "Type", "IsPrimary") + .HasDatabaseName("ix_user_identity_primary_per_type"); + + b.ToTable("user_identities", null, t => + { + t.HasCheckConstraint("ck_user_identities_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserMfaFactor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CredentialId") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Label") + .HasColumnType("text"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PhoneE164") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("Secret") + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("user_mfa_factors", null, t => + { + t.HasCheckConstraint("ck_user_mfa_factors_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserPasswordHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChangedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "ChangedAt"); + + b.ToTable("user_password_histories", null, t => + { + t.HasCheckConstraint("ck_user_password_histories_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("user_roles", null, t => + { + t.HasCheckConstraint("ck_user_roles_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DeviceId") + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserAgent") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "DeviceId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("user_sessions", null, t => + { + t.HasCheckConstraint("ck_user_sessions_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ParentDepartmentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("ParentDepartmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("departments", null, t => + { + t.HasCheckConstraint("ck_departments_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmergencyContact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Relationship") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserProfileId"); + + b.HasIndex("TenantId", "UserProfileId", "IsPrimary"); + + b.ToTable("emergency_contacts", null, t => + { + t.HasCheckConstraint("ck_emergency_contacts_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Line2") + .HasColumnType("text"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserProfileId"); + + b.HasIndex("TenantId", "UserProfileId", "IsPrimary"); + + b.ToTable("employee_addresses", null, t => + { + t.HasCheckConstraint("ck_employee_addresses_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmployeeBankAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountHolder") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("BankName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Branch") + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserProfileId"); + + b.HasIndex("TenantId", "UserProfileId", "IsPrimary"); + + b.ToTable("employee_bank_accounts", null, t => + { + t.HasCheckConstraint("ck_employee_bank_accounts_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Employment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("EmploymentType") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("ManagerUserId") + .HasColumnType("uuid"); + + b.Property("PositionId") + .HasColumnType("uuid"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserProfileId") + .HasColumnType("uuid"); + + b.Property("WorkEmail") + .HasColumnType("text"); + + b.Property("WorkPhone") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PositionId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserProfileId"); + + b.HasIndex("TenantId", "UserProfileId", "StartDate"); + + b.ToTable("employments", null, t => + { + t.HasCheckConstraint("ck_employments_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Position", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("positions", null, t => + { + t.HasCheckConstraint("ck_positions_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("DepartmentId") + .HasColumnType("uuid"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_deleted"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MiddleName") + .HasColumnType("text"); + + b.Property("Nickname") + .HasColumnType("text"); + + b.Property("PositionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("tenant_id") + .HasDefaultValueSql("nullif(current_setting('app.tenant_id', true),'')::uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UpdatedBy") + .HasColumnType("text") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("PositionId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("TenantId", "UserId") + .IsUnique(); + + b.ToTable("user_profiles", null, t => + { + t.HasCheckConstraint("ck_user_profiles_tenant_not_null", "tenant_id is not null"); + }); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Tenancy.TenantConfig", b => + { + b.Property("TenantKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConnectionString") + .HasColumnType("text"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Schema") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("TenantKey"); + + b.HasIndex("IsActive"); + + b.ToTable("tenants", "meta"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Tenancy.TenantDomain", b => + { + b.Property("Domain") + .HasMaxLength(253) + .HasColumnType("character varying(253)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsPlatformBaseDomain") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("TenantKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc") + .HasDefaultValueSql("now() at time zone 'utc'"); + + b.HasKey("Domain"); + + b.HasIndex("IsActive"); + + b.HasIndex("IsPlatformBaseDomain"); + + b.HasIndex("TenantKey"); + + b.ToTable("tenant_domains", "meta"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.RolePermission", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.Permission", "Permission") + .WithMany("RolePermissions") + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserExternalAccount", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("ExternalAccounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserIdentity", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("Identities") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserMfaFactor", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("MfaFactors") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserPasswordHistory", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("PasswordHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserRole", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.UserSession", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Department", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Department", "Parent") + .WithMany("Children") + .HasForeignKey("ParentDepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmergencyContact", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserProfile") + .WithMany("EmergencyContacts") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmployeeAddress", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserProfile") + .WithMany("Addresses") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.EmployeeBankAccount", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserProfile") + .WithMany("BankAccounts") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Employment", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Position", "Position") + .WithMany() + .HasForeignKey("PositionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserProfile") + .WithMany("Employments") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Department"); + + b.Navigation("Position"); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Department", null) + .WithMany("Profiles") + .HasForeignKey("DepartmentId"); + + b.HasOne("AMREZ.EOP.Domain.Entities.HumanResources.Position", null) + .WithMany("Profiles") + .HasForeignKey("PositionId"); + + b.HasOne("AMREZ.EOP.Domain.Entities.Authentications.User", "User") + .WithOne() + .HasForeignKey("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Tenancy.TenantDomain", b => + { + b.HasOne("AMREZ.EOP.Domain.Entities.Tenancy.TenantConfig", null) + .WithMany() + .HasForeignKey("TenantKey") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.Permission", b => + { + b.Navigation("RolePermissions"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.Authentications.User", b => + { + b.Navigation("ExternalAccounts"); + + b.Navigation("Identities"); + + b.Navigation("MfaFactors"); + + b.Navigation("PasswordHistories"); + + b.Navigation("Sessions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Department", b => + { + b.Navigation("Children"); + + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.Position", b => + { + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("AMREZ.EOP.Domain.Entities.HumanResources.UserProfile", b => + { + b.Navigation("Addresses"); + + b.Navigation("BankAccounts"); + + b.Navigation("EmergencyContacts"); + + b.Navigation("Employments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AMREZ.EOP.Infrastructures/Options/AuthOptions.cs b/AMREZ.EOP.Infrastructures/Options/AuthOptions.cs new file mode 100644 index 0000000..b998383 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Options/AuthOptions.cs @@ -0,0 +1,10 @@ +namespace AMREZ.EOP.Infrastructures.Options; + +public sealed class AuthOptions +{ + public string DefaultConnection { get; set; } = default!; + public bool UseSchemaPerTenant { get; set; } = true; + public string StorageBackend { get; set; } = "ef"; + public string? RedisConnection { get; set; } + public int RedisDb { get; set; } = 0; +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Repositories/TenantRepository.cs b/AMREZ.EOP.Infrastructures/Repositories/TenantRepository.cs new file mode 100644 index 0000000..e503eae --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Repositories/TenantRepository.cs @@ -0,0 +1,172 @@ +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Abstractions.Storage; +using AMREZ.EOP.Domain.Entities.Tenancy; +using AMREZ.EOP.Infrastructures.Data; +using AMREZ.EOP.Infrastructures.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace AMREZ.EOP.Infrastructures.Repositories; + +public sealed class TenantRepository : ITenantRepository +{ + private readonly IDbScope _scope; + public TenantRepository(IDbScope scope) => _scope = scope; + + private AppDbContext Db() => _scope.Get(); + + private static string Norm(string s) => (s ?? string.Empty).Trim().ToLowerInvariant(); + + public Task TenantExistsAsync(string tenantKey, CancellationToken ct = default) + { + var key = Norm(tenantKey); + return Db().Set().AsNoTracking().AnyAsync(x => x.TenantKey == key, ct); + } + + public Task GetAsync(string tenantKey, CancellationToken ct = default) + { + var key = Norm(tenantKey); + return Db().Set().AsNoTracking().FirstOrDefaultAsync(x => x.TenantKey == key, ct); + } + + public async Task> ListTenantsAsync(CancellationToken ct = default) + => await Db().Set().AsNoTracking().OrderBy(x => x.TenantKey).ToListAsync(ct); + + public async Task CreateAsync(TenantConfig row, CancellationToken ct = default) + { + row.TenantKey = Norm(row.TenantKey); + row.Schema = string.IsNullOrWhiteSpace(row.Schema) ? null : row.Schema!.Trim(); + row.ConnectionString = string.IsNullOrWhiteSpace(row.ConnectionString) ? null : row.ConnectionString!.Trim(); + row.UpdatedAtUtc = DateTimeOffset.UtcNow; + + await Db().Set().AddAsync(row, ct); + return await Db().SaveChangesAsync(ct) > 0; + } + + public async Task UpdateAsync(TenantConfig row, DateTimeOffset? ifUnmodifiedSince, CancellationToken ct = default) + { + var key = Norm(row.TenantKey); + var db = Db(); + var cur = await db.Set().FirstOrDefaultAsync(x => x.TenantKey == key, ct); + if (cur is null) return false; + + if (ifUnmodifiedSince.HasValue && cur.UpdatedAtUtc > ifUnmodifiedSince.Value) + return false; // 412 + + cur.Schema = row.Schema is null ? cur.Schema : (string.IsNullOrWhiteSpace(row.Schema) ? null : row.Schema.Trim()); + cur.ConnectionString = row.ConnectionString is null ? cur.ConnectionString : (string.IsNullOrWhiteSpace(row.ConnectionString) ? null : row.ConnectionString.Trim()); + cur.Mode = row.Mode; + cur.IsActive = row.IsActive; + cur.UpdatedAtUtc = DateTimeOffset.UtcNow; + + return await db.SaveChangesAsync(ct) > 0; + } + + public async Task DeleteAsync(string tenantKey, CancellationToken ct = default) + { + var key = Norm(tenantKey); + var db = Db(); + + var t = await db.Set().FirstOrDefaultAsync(x => x.TenantKey == key, ct); + if (t is null) return false; + + // ❌ ไม่ลบนะ — ✅ deactivate + if (t.IsActive) + { + t.IsActive = false; + t.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + var domains = await db.Set() + .Where(d => d.TenantKey == key && d.IsActive) + .ToListAsync(ct); + + foreach (var d in domains) + { + d.IsActive = false; + d.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + + return await db.SaveChangesAsync(ct) > 0; + } + + public async Task MapDomainAsync(string domain, string tenantKey, CancellationToken ct = default) + { + var d = Norm(domain); + var key = Norm(tenantKey); + + if (!await Db().Set().AnyAsync(x => x.TenantKey == key && x.IsActive, ct)) + return false; + + var db = Db(); + var ex = await db.Set().FirstOrDefaultAsync(x => x.Domain == d, ct); + if (ex is null) + await db.Set().AddAsync(new TenantDomain { Domain = d, TenantKey = key, IsPlatformBaseDomain = false, IsActive = true, UpdatedAtUtc = DateTimeOffset.UtcNow }, ct); + else + { + ex.TenantKey = key; + ex.IsPlatformBaseDomain = false; + ex.IsActive = true; + ex.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + return await db.SaveChangesAsync(ct) > 0; + } + + public async Task UnmapDomainAsync(string domain, CancellationToken ct = default) + { + var d = Norm(domain); + var db = Db(); + var ex = await db.Set().FirstOrDefaultAsync(x => x.Domain == d, ct); + if (ex is null) return false; + + ex.IsActive = false; + ex.TenantKey = null; + ex.IsPlatformBaseDomain = false; + ex.UpdatedAtUtc = DateTimeOffset.UtcNow; + + return await db.SaveChangesAsync(ct) > 0; + } + + public async Task> ListDomainsAsync(string? tenantKey, CancellationToken ct = default) + { + var db = Db(); + var q = db.Set().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(tenantKey)) + { + var key = Norm(tenantKey!); + q = q.Where(x => x.TenantKey == key || x.IsPlatformBaseDomain); + } + return await q.OrderBy(x => x.Domain).ToListAsync(ct); + } + + public async Task AddBaseDomainAsync(string baseDomain, CancellationToken ct = default) + { + var d = Norm(baseDomain); + var db = Db(); + var ex = await db.Set().FirstOrDefaultAsync(x => x.Domain == d, ct); + if (ex is null) + await db.Set().AddAsync(new TenantDomain { Domain = d, TenantKey = null, IsPlatformBaseDomain = true, IsActive = true, UpdatedAtUtc = DateTimeOffset.UtcNow }, ct); + else + { + ex.TenantKey = null; + ex.IsPlatformBaseDomain = true; + ex.IsActive = true; + ex.UpdatedAtUtc = DateTimeOffset.UtcNow; + } + return await db.SaveChangesAsync(ct) > 0; + } + + public async Task RemoveBaseDomainAsync(string baseDomain, CancellationToken ct = default) + { + var d = Norm(baseDomain); + var db = Db(); + var ex = await db.Set() + .FirstOrDefaultAsync(x => x.Domain == d && x.IsPlatformBaseDomain, ct); + if (ex is null) return false; + + ex.IsActive = false; + ex.UpdatedAtUtc = DateTimeOffset.UtcNow; + + return await db.SaveChangesAsync(ct) > 0; + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Repositories/UserProfileRepository.cs b/AMREZ.EOP.Infrastructures/Repositories/UserProfileRepository.cs new file mode 100644 index 0000000..a72d217 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Repositories/UserProfileRepository.cs @@ -0,0 +1,144 @@ +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Abstractions.Storage; +using AMREZ.EOP.Domain.Entities.HumanResources; +using AMREZ.EOP.Infrastructures.Data; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace AMREZ.EOP.Infrastructures.Repositories; + +public class UserProfileRepository : IUserProfileRepository +{ + private readonly IDbScope _scope; + private readonly ITenantResolver _tenantResolver; + private readonly IHttpContextAccessor _http; + + public UserProfileRepository(IDbScope scope, ITenantResolver tenantResolver, IHttpContextAccessor http) + { _scope = scope; _tenantResolver = tenantResolver; _http = http; } + + private Guid TenantId() + { + var http = _http.HttpContext; + var tc = http is not null ? _tenantResolver.Resolve(http) : null; + return Guid.TryParse(tc?.Id, out var g) ? g : Guid.Empty; + } + + public async Task GetByUserIdAsync(Guid userId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + return await db.UserProfiles.FirstOrDefaultAsync(p => p.TenantId == tid && p.UserId == userId, ct); + } + + public async Task UpsertAsync(UserProfile profile, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var existing = await db.UserProfiles.FirstOrDefaultAsync(p => p.TenantId == tid && p.UserId == profile.UserId, ct); + if (existing is null) + { + profile.TenantId = tid; + await db.UserProfiles.AddAsync(profile, ct); + } + else + { + existing.FirstName = profile.FirstName; + existing.LastName = profile.LastName; + existing.MiddleName = profile.MiddleName; + existing.Nickname = profile.Nickname; + existing.DateOfBirth = profile.DateOfBirth; + existing.Gender = profile.Gender; + } + await db.SaveChangesAsync(ct); + } + + public async Task AddEmploymentAsync(Employment e, CancellationToken ct = default) + { + var db = _scope.Get(); + e.TenantId = TenantId(); + await db.Employments.AddAsync(e, ct); + await db.SaveChangesAsync(ct); + return e; + } + + public async Task EndEmploymentAsync(Guid employmentId, DateTime endDate, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + var e = await db.Employments.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == employmentId, ct); + if (e is null) return; + e.EndDate = endDate; + await db.SaveChangesAsync(ct); + } + + public async Task AddAddressAsync(EmployeeAddress a, CancellationToken ct = default) + { + var db = _scope.Get(); + a.TenantId = TenantId(); + await db.EmployeeAddresses.AddAsync(a, ct); + await db.SaveChangesAsync(ct); + return a; + } + + public async Task SetPrimaryAddressAsync(Guid userProfileId, Guid addressId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var all = await db.EmployeeAddresses.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct); + foreach (var x in all) x.IsPrimary = false; + + var target = all.FirstOrDefault(x => x.Id == addressId); + if (target is not null) target.IsPrimary = true; + + await db.SaveChangesAsync(ct); + } + + public async Task AddEmergencyContactAsync(EmergencyContact c, CancellationToken ct = default) + { + var db = _scope.Get(); + c.TenantId = TenantId(); + await db.EmergencyContacts.AddAsync(c, ct); + await db.SaveChangesAsync(ct); + return c; + } + + public async Task SetPrimaryEmergencyContactAsync(Guid userProfileId, Guid contactId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var all = await db.EmergencyContacts.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct); + foreach (var x in all) x.IsPrimary = false; + + var target = all.FirstOrDefault(x => x.Id == contactId); + if (target is not null) target.IsPrimary = true; + + await db.SaveChangesAsync(ct); + } + + public async Task AddBankAccountAsync(EmployeeBankAccount b, CancellationToken ct = default) + { + var db = _scope.Get(); + b.TenantId = TenantId(); + await db.EmployeeBankAccounts.AddAsync(b, ct); + await db.SaveChangesAsync(ct); + return b; + } + + public async Task SetPrimaryBankAccountAsync(Guid userProfileId, Guid bankAccountId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var all = await db.EmployeeBankAccounts.Where(x => x.TenantId == tid && x.UserProfileId == userProfileId).ToListAsync(ct); + foreach (var x in all) x.IsPrimary = false; + + var target = all.FirstOrDefault(x => x.Id == bankAccountId); + if (target is not null) target.IsPrimary = true; + + await db.SaveChangesAsync(ct); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Repositories/UserRepository.cs b/AMREZ.EOP.Infrastructures/Repositories/UserRepository.cs new file mode 100644 index 0000000..a5cfa9e --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Repositories/UserRepository.cs @@ -0,0 +1,329 @@ +using System.Text.Json; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Repositories; +using AMREZ.EOP.Abstractions.Storage; +using AMREZ.EOP.Domain.Entities.Authentications; +using AMREZ.EOP.Domain.Shared._Users; +using AMREZ.EOP.Infrastructures.Data; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; + +namespace AMREZ.EOP.Infrastructures.Repositories; + +public class UserRepository : IUserRepository +{ + private readonly IDbScope _scope; + private readonly IConnectionMultiplexer? _redis; + private readonly ITenantResolver _tenantResolver; + private readonly IHttpContextAccessor _http; + + public UserRepository( + IDbScope scope, + IConnectionMultiplexer? redis, + ITenantResolver tenantResolver, + IHttpContextAccessor http) + { + _scope = scope; + _redis = redis; // null = ไม่มี/ต่อไม่ได้ → ข้าม cache + _tenantResolver = tenantResolver; + _http = http; + } + + private Guid TenantId() + { + var http = _http.HttpContext; + var tc = http is not null ? _tenantResolver.Resolve(http) : null; + return Guid.TryParse(tc?.Id, out var g) ? g : Guid.Empty; + } + + private static string IdentityEmailKey(string tenantId, string email) + => $"eop:{tenantId}:user:identity:email:{email.Trim().ToLowerInvariant()}"; + + private static string UserIdKey(string tenantId, Guid id) + => $"eop:{tenantId}:user:id:{id:N}"; + + public async Task FindByIdAsync(Guid userId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + return await db.Users.AsNoTracking() + .FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct); + } + + public async Task FindActiveByEmailAsync(string email, CancellationToken ct = default) + { + var tid = TenantId(); + var tidStr = tid.ToString(); + var r = _redis?.GetDatabase(); + + // 1) Redis + if (r is not null) + { + try + { + var cached = await r.StringGetAsync(IdentityEmailKey(tidStr, email)); + if (cached.HasValue) + { + var hit = JsonSerializer.Deserialize(cached!); + if (hit?.IsActive == true) return hit; + } + } + catch { /* ignore → query DB */ } + } + + // 2) EF: join identities + var db = _scope.Get(); + var norm = email.Trim().ToLowerInvariant(); + + var user = await db.Users + .AsNoTracking() + .Where(u => u.TenantId == tid && u.IsActive) + .Where(u => db.UserIdentities.Any(i => + i.TenantId == tid && + i.UserId == u.Id && + i.Type == IdentityType.Email && + i.Identifier == norm)) + .FirstOrDefaultAsync(ct); + + // 3) cache + if (user is not null && r is not null) + { + try + { + var payload = JsonSerializer.Serialize(user); + var ttl = TimeSpan.FromMinutes(5); + await r.StringSetAsync(IdentityEmailKey(tidStr, norm), payload, ttl); + await r.StringSetAsync(UserIdKey(tidStr, user.Id), payload, ttl); + } + catch { /* ignore */ } + } + + return user; + } + + public async Task EmailExistsAsync(string email, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + var norm = email.Trim().ToLowerInvariant(); + + return await db.UserIdentities.AsNoTracking() + .AnyAsync(i => i.TenantId == tid && i.Type == IdentityType.Email && i.Identifier == norm, ct); + } + + public async Task AddAsync(User user, CancellationToken ct = default) + { + var db = _scope.Get(); + user.TenantId = TenantId(); + + await db.Users.AddAsync(user, ct); + await db.SaveChangesAsync(ct); + + // cache + var r = _redis?.GetDatabase(); + if (r is not null) + { + try + { + var tid = user.TenantId.ToString(); + var payload = JsonSerializer.Serialize(user); + var ttl = TimeSpan.FromMinutes(5); + + await r.StringSetAsync(UserIdKey(tid, user.Id), payload, ttl); + + var email = user.Identities + .FirstOrDefault(i => i.Type == IdentityType.Email && i.IsPrimary)?.Identifier; + if (!string.IsNullOrWhiteSpace(email)) + await r.StringSetAsync(IdentityEmailKey(tid, email!), payload, ttl); + } + catch { /* ignore */ } + } + } + + // ========= Identities ========= + + // (1) IMPLEMENTED: AddIdentityAsync + public async Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + var norm = identifier.Trim().ToLowerInvariant(); + + if (isPrimary) + { + var existingPrimary = await db.UserIdentities + .Where(i => i.TenantId == tid && i.UserId == userId && i.Type == type && i.IsPrimary) + .ToListAsync(ct); + foreach (var x in existingPrimary) x.IsPrimary = false; + } + + var entity = new UserIdentity + { + TenantId = tid, + UserId = userId, + Type = type, + Identifier = norm, + IsPrimary = isPrimary + }; + + await db.UserIdentities.AddAsync(entity, ct); + await db.SaveChangesAsync(ct); + } + + // (2) IMPLEMENTED: VerifyIdentityAsync + public async Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + var norm = identifier.Trim().ToLowerInvariant(); + + var id = await db.UserIdentities + .FirstOrDefaultAsync(i => + i.TenantId == tid && + i.UserId == userId && + i.Type == type && + i.Identifier == norm, ct); + + if (id is null) return; + id.VerifiedAt = verifiedAt; + await db.SaveChangesAsync(ct); + } + + // (3) IMPLEMENTED: GetPrimaryIdentityAsync + public async Task GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + return await db.UserIdentities.AsNoTracking() + .FirstOrDefaultAsync(i => + i.TenantId == tid && + i.UserId == userId && + i.Type == type && + i.IsPrimary, ct); + } + + // ========= Password ========= + + public async Task ChangePasswordAsync(Guid userId, string newPasswordHash, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tid && u.Id == userId, ct); + if (user is null) return; + + user.PasswordHash = newPasswordHash; + user.SecurityStamp = Guid.NewGuid().ToString("N"); + await db.SaveChangesAsync(ct); + } + + public async Task AddPasswordHistoryAsync(Guid userId, string passwordHash, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var h = new UserPasswordHistory + { + TenantId = tid, + UserId = userId, + PasswordHash = passwordHash, + ChangedAt = DateTimeOffset.UtcNow + }; + + await db.UserPasswordHistories.AddAsync(h, ct); + await db.SaveChangesAsync(ct); + } + + // ========= MFA ========= + + public async Task AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var f = new UserMfaFactor + { + TenantId = tid, + UserId = userId, + Type = MfaType.Totp, + Label = label, + Secret = secret, + Enabled = true, + AddedAt = DateTimeOffset.UtcNow + }; + + await db.UserMfaFactors.AddAsync(f, ct); + + var u = await db.Users.FirstAsync(x => x.TenantId == tid && x.Id == userId, ct); + u.MfaEnabled = true; + + await db.SaveChangesAsync(ct); + return f; + } + + public async Task DisableMfaFactorAsync(Guid factorId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var f = await db.UserMfaFactors.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == factorId, ct); + if (f is null) return; + + f.Enabled = false; + + var still = await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == f.UserId && x.Enabled, ct); + if (!still) + { + var u = await db.Users.FirstAsync(x => x.TenantId == tid && x.Id == f.UserId, ct); + u.MfaEnabled = false; + } + + await db.SaveChangesAsync(ct); + } + + public async Task HasAnyMfaAsync(Guid userId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + return await db.UserMfaFactors.AnyAsync(x => x.TenantId == tid && x.UserId == userId && x.Enabled, ct); + } + + // ========= Sessions ========= + + public async Task CreateSessionAsync(UserSession session, CancellationToken ct = default) + { + var db = _scope.Get(); + session.TenantId = TenantId(); + await db.UserSessions.AddAsync(session, ct); + await db.SaveChangesAsync(ct); + return session; + } + + public async Task RevokeSessionAsync(Guid userId, Guid sessionId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var s = await db.UserSessions.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == sessionId && x.UserId == userId, ct); + if (s is null) return 0; + + s.RevokedAt = DateTimeOffset.UtcNow; + return await db.SaveChangesAsync(ct); + } + + public async Task RevokeAllSessionsAsync(Guid userId, CancellationToken ct = default) + { + var db = _scope.Get(); + var tid = TenantId(); + + var sessions = await db.UserSessions + .Where(x => x.TenantId == tid && x.UserId == userId && x.RevokedAt == null) + .ToListAsync(ct); + + foreach (var s in sessions) s.RevokedAt = DateTimeOffset.UtcNow; + + return await db.SaveChangesAsync(ct); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Security/BcryptPasswordHasher.cs b/AMREZ.EOP.Infrastructures/Security/BcryptPasswordHasher.cs new file mode 100644 index 0000000..3a96f79 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Security/BcryptPasswordHasher.cs @@ -0,0 +1,61 @@ +using System.Security.Cryptography; +using System.Text; +using AMREZ.EOP.Abstractions.Security; + +namespace AMREZ.EOP.Infrastructures.Security; + +public sealed class BcryptPasswordHasher : IPasswordHasher +{ + private const int SaltSizeBytes = 16; // 128-bit salt + private const int KeySizeBytes = 32; // 256-bit key + private const int Iterations = 200_000; // 2e5 (แนะนำ >= 150k บน .NET 8/9) + + public bool Verify(string plain, string hash) + { + if (plain is null || hash is null) return false; + + var parts = hash.Split('$', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 4 || !parts[0].Equals("pbkdf2-sha256", StringComparison.OrdinalIgnoreCase)) + return false; + + if (!int.TryParse(parts[1], out var iters) || iters <= 0) return false; + + byte[] salt, expected; + try + { + salt = Convert.FromBase64String(parts[2]); + expected = Convert.FromBase64String(parts[3]); + } + catch { return false; } + + byte[] actual = Rfc2898DeriveBytes.Pbkdf2( + password: Encoding.UTF8.GetBytes(plain), + salt: salt, + iterations: iters, + hashAlgorithm: HashAlgorithmName.SHA256, + outputLength: expected.Length + ); + + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + public string Hash(string plain) + { + if (plain is null) throw new ArgumentNullException(nameof(plain)); + + Span salt = stackalloc byte[SaltSizeBytes]; + RandomNumberGenerator.Fill(salt); + + byte[] key = Rfc2898DeriveBytes.Pbkdf2( + password: Encoding.UTF8.GetBytes(plain), + salt: salt.ToArray(), + iterations: Iterations, + hashAlgorithm: HashAlgorithmName.SHA256, + outputLength: KeySizeBytes + ); + + var saltB64 = Convert.ToBase64String(salt); + var keyB64 = Convert.ToBase64String(key); + return $"pbkdf2-sha256${Iterations}${saltB64}${keyB64}"; + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Storage/DbScope.cs b/AMREZ.EOP.Infrastructures/Storage/DbScope.cs new file mode 100644 index 0000000..b506574 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Storage/DbScope.cs @@ -0,0 +1,64 @@ +using System.Collections.Concurrent; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Storage; +using AMREZ.EOP.Infrastructures.Data; +using AMREZ.EOP.Infrastructures.Options; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using IDatabase = Microsoft.EntityFrameworkCore.Storage.IDatabase; + +namespace AMREZ.EOP.Infrastructures.Storage; + +public sealed class DbScope : IDbScope, IAsyncDisposable +{ + private readonly ITenantDbContextFactory _factory; + private readonly IOptions _opts; + private readonly IConnectionMultiplexer? _redis; + private readonly ConcurrentDictionary _cache = new(); + + public DbScope(ITenantDbContextFactory factory, IOptions opts, IConnectionMultiplexer? redis = null) + { _factory = factory; _opts = opts; _redis = redis; } + + private ITenantContext? _tenant; + + public void EnsureForTenant(ITenantContext tenant) + { + if (_tenant?.Id == tenant.Id) return; + _tenant = tenant; + _cache.Clear(); + var ef = _factory.Create(tenant); + _cache[typeof(AppDbContext)] = ef; + if (_redis != null) + _cache[typeof(IDatabase)] = _redis.GetDatabase(_opts.Value.RedisDb); + } + + public TContext Get() where TContext : class + { + if (_tenant is null) throw new InvalidOperationException("Tenant not set. Call EnsureForTenant first."); + if (_cache.TryGetValue(typeof(TContext), out var obj)) return (TContext)obj; + + if (typeof(TContext) == typeof(AppDbContext)) + { + var ef = _factory.Create(_tenant); + _cache[typeof(AppDbContext)] = ef; + return (TContext)(object)ef; + } + + if (typeof(TContext) == typeof(IDatabase)) + { + if (_redis is null) throw new InvalidOperationException("Redis not configured"); + var db = _redis.GetDatabase(_opts.Value.RedisDb); + _cache[typeof(IDatabase)] = db; + return (TContext)(object)db; + } + + throw new NotSupportedException($"Unknown context type {typeof(TContext).Name}"); + } + + public async ValueTask DisposeAsync() + { + if (_cache.TryGetValue(typeof(AppDbContext), out var ef) && ef is AppDbContext db) + await db.DisposeAsync(); + _cache.Clear(); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Tenancy/DefaultTenantResolver.cs b/AMREZ.EOP.Infrastructures/Tenancy/DefaultTenantResolver.cs new file mode 100644 index 0000000..d42e3ad --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Tenancy/DefaultTenantResolver.cs @@ -0,0 +1,98 @@ +using System.Text.RegularExpressions; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Contracts.DTOs.Authentications.Login; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace AMREZ.EOP.Infrastructures.Tenancy; + +public sealed class DefaultTenantResolver : ITenantResolver +{ + private readonly TenantMap _map; + private readonly ILogger? _log; + + // แพลตฟอร์มสลัก (ปรับตามระบบจริงได้) — ใช้ "public" เป็นค่าเริ่ม + private const string PlatformSlug = "public"; + + public DefaultTenantResolver( + TenantMap map, + ILogger? log = null + ) + { + _map = map; + _log = log; + } + + public ITenantContext? Resolve(HttpContext http, object? hint = null) + { + var cid = http.TraceIdentifier; + var path = http.Request.Path.Value ?? string.Empty; + + // ✅ ทางลัด: ขอ platform ตรง ๆ ด้วย hint + if (hint is string hs && string.Equals(hs, "@platform", StringComparison.OrdinalIgnoreCase)) + { + if (_map.TryGetBySlug(PlatformSlug, out var platform)) + { + _log?.LogInformation("Resolved by hint=@platform -> {Slug} cid={Cid}", PlatformSlug, cid); + return platform; + } + _log?.LogWarning("Platform slug '{Slug}' not found in TenantMap cid={Cid}", PlatformSlug, cid); + return null; + } + + if (path.StartsWith("/api/Tenancy", StringComparison.OrdinalIgnoreCase)) + { + if (_map.TryGetBySlug(PlatformSlug, out var platform)) + { + // เก็บ target tenant แยกไว้ให้ repo/usecase ใช้ + var headerTenant = http.Request.Headers["X-Tenant"].ToString(); + if (!string.IsNullOrWhiteSpace(headerTenant)) + http.Items["TargetTenantKey"] = headerTenant.Trim().ToLowerInvariant(); + + _log?.LogInformation("Resolved CONTROL-PLANE -> platform:{Slug} (path={Path}) cid={Cid}", PlatformSlug, path, cid); + return platform; + } + _log?.LogWarning("CONTROL-PLANE path but platform slug '{Slug}' missing cid={Cid}", PlatformSlug, cid); + return null; + } + + var header = http.Request.Headers["X-Tenant"].ToString(); + if (!string.IsNullOrWhiteSpace(header) && _map.TryGetBySlug(header, out var byHeader)) + { + _log?.LogInformation("Resolved by header: {Tenant} cid={Cid}", header, cid); + return byHeader; + } + + var host = http.Request.Host.Host; + if (_map.TryGetByDomain(host, out var byDomain)) + { + _log?.LogInformation("Resolved by domain: {Host} -> {TenantId} cid={Cid}", host, byDomain.Id, cid); + return byDomain; + } + + var sub = GetSubdomain(host); + if (sub != null && _map.TryGetBySlug(sub, out var bySub)) + { + _log?.LogInformation("Resolved by subdomain: {Sub} cid={Cid}", sub, cid); + return bySub; + } + + if (hint is LoginRequest body && !string.IsNullOrWhiteSpace(body.Tenant) && + _map.TryGetBySlug(body.Tenant!, out var byBody)) + { + _log?.LogInformation("Resolved by body: {Tenant} cid={Cid}", body.Tenant, cid); + return byBody; + } + + _log?.LogWarning("Resolve FAILED host={Host} header={Header} cid={Cid}", + host, string.IsNullOrWhiteSpace(header) ? "" : header, cid); + return null; + } + + private static string? GetSubdomain(string host) + { + if (Regex.IsMatch(host, @"^\d{1,3}(\.\d{1,3}){3}$")) return null; // IP + var parts = host.Split('.', StringSplitOptions.RemoveEmptyEntries); + return parts.Length >= 3 ? parts[0] : null; + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Tenancy/SearchPathInterceptor.cs b/AMREZ.EOP.Infrastructures/Tenancy/SearchPathInterceptor.cs new file mode 100644 index 0000000..a1f51f1 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Tenancy/SearchPathInterceptor.cs @@ -0,0 +1,35 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace AMREZ.EOP.Infrastructures.Tenancy; + +public sealed class SearchPathInterceptor : DbConnectionInterceptor +{ + private string _schema = "public"; + private bool _enabled; + + public void Configure(string? schema, bool enabled) + { + _enabled = enabled; + _schema = string.IsNullOrWhiteSpace(schema) ? "public" : schema.Trim(); + } + + public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken ct = default) + { + if (_enabled) + { + var schema = string.IsNullOrWhiteSpace(_schema) ? "public" : _schema.Replace("\"", "\"\""); + await using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = $"CREATE SCHEMA IF NOT EXISTS \"{schema}\";"; + await cmd.ExecuteNonQueryAsync(ct); + } + await using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = $"SET LOCAL search_path TO \"{schema}\", pg_catalog;"; + await cmd.ExecuteNonQueryAsync(ct); + } + } + await base.ConnectionOpenedAsync(connection, eventData, ct); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Tenancy/TenantContext.cs b/AMREZ.EOP.Infrastructures/Tenancy/TenantContext.cs new file mode 100644 index 0000000..a69e40e --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Tenancy/TenantContext.cs @@ -0,0 +1,14 @@ +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Domain.Shared.Tenancy; + +namespace AMREZ.EOP.Infrastructures.Tenancy; + +public class TenantContext : ITenantContext +{ + public string TenantKey { get; init; } + public string Id { get; init; } = default!; + public string? Schema { get; init; } + public string? ConnectionString { get; init; } + public TenantMode Mode { get; init; } = TenantMode.Rls; + public bool IsActive { get; init; } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Tenancy/TenantDbContextFactory.cs b/AMREZ.EOP.Infrastructures/Tenancy/TenantDbContextFactory.cs new file mode 100644 index 0000000..75ab57e --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Tenancy/TenantDbContextFactory.cs @@ -0,0 +1,55 @@ +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Domain.Shared.Tenancy; +using AMREZ.EOP.Infrastructures.Data; +using AMREZ.EOP.Infrastructures.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace AMREZ.EOP.Infrastructures.Tenancy; + +public sealed class TenantDbContextFactory : ITenantDbContextFactory +{ + private readonly IOptions _opts; + private readonly SearchPathInterceptor _searchPath; + private readonly TenantRlsInterceptor _rls; + + public TenantDbContextFactory(IOptions opts, SearchPathInterceptor searchPath, TenantRlsInterceptor rls) + { _opts = opts; _searchPath = searchPath; _rls = rls; } + + public TContext Create(ITenantContext t) where TContext : class + { + if (typeof(TContext) != typeof(AppDbContext)) + throw new NotSupportedException($"Unsupported context: {typeof(TContext).Name}"); + + var builder = new DbContextOptionsBuilder(); + + // ✅ การ์ด: ถ้า connection string ของ tenant ไม่ใช่รูปแบบ key=value ให้ fallback ไป DefaultConnection (platform) + var raw = (t.ConnectionString ?? string.Empty).Trim(); + string conn = IsLikelyValidConnectionString(raw) + ? raw + : (_opts.Value.DefaultConnection ?? string.Empty).Trim(); + + if (!IsLikelyValidConnectionString(conn)) + throw new InvalidOperationException( + $"Invalid connection string for tenant '{t.TenantKey}'. Provide a valid per-tenant or DefaultConnection."); + + builder.UseNpgsql(conn); + + // RLS per-tenant (ถ้า platform ควรกำหนดให้ Interceptor ทำงานในโหมด platform/disable ตามที่คุณนิยาม) + _rls.Configure(t.Id); + builder.AddInterceptors(_rls); + + // ใช้ schema ต่อ tenant เมื่อเปิดโหมดนี้ + if (_opts.Value.UseSchemaPerTenant) + { + var schema = string.IsNullOrWhiteSpace(t.Schema) ? t.Id : t.Schema!.Trim(); + _searchPath.Configure(schema, enabled: true); // CREATE IF NOT EXISTS + SET LOCAL search_path + builder.AddInterceptors(_searchPath); + } + + return (new AppDbContext(builder.Options) as TContext)!; + } + + private static bool IsLikelyValidConnectionString(string s) + => !string.IsNullOrWhiteSpace(s) && s.Contains('='); +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Tenancy/TenantMap.cs b/AMREZ.EOP.Infrastructures/Tenancy/TenantMap.cs new file mode 100644 index 0000000..aba485d --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Tenancy/TenantMap.cs @@ -0,0 +1,57 @@ +using System.Collections.Concurrent; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Domain.Shared.Tenancy; + +namespace AMREZ.EOP.Infrastructures.Tenancy; + +/// +/// ศูนย์รวม mapping แบบไดนามิก: +/// - Tenants: slug/key -> TenantContext +/// - DomainToTenant: custom-domain -> tenantKey +/// - BaseDomains: ชุดโดเมนแพลตฟอร์มที่รองรับรูปแบบ *.basedomain (รองรับหลายอันได้) +/// คุณสามารถเติม/แก้ไขระหว่างรันงานผ่าน Admin API หรือ job reload จาก DB ได้เลย +/// +public sealed class TenantMap +{ + private readonly ConcurrentDictionary _tenants = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _domainToTenant = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _baseDomains = new(StringComparer.OrdinalIgnoreCase); + + // ====== Query ====== + public bool TryGetBySlug(string slug, out ITenantContext ctx) + { + if (_tenants.TryGetValue(slug, out var t)) + { + ctx = t; return true; + } + ctx = null!; return false; + } + + public bool TryGetByDomain(string domain, out ITenantContext ctx) + { + if (_domainToTenant.TryGetValue(domain, out var key) && _tenants.TryGetValue(key, out var t)) + { ctx = t; return true; } + ctx = null!; return false; + } + + public bool TryGetBaseDomainFor(string host, out string? baseDomain) + { + foreach (var kv in _baseDomains.Keys) + { + var bd = kv; + if (host.EndsWith(bd, StringComparison.OrdinalIgnoreCase) && !host.Equals(bd, StringComparison.OrdinalIgnoreCase)) + { baseDomain = bd; return true; } + } + baseDomain = null; return false; + } + + // ====== Admin / Reload ====== + public void UpsertTenant(string key, TenantContext ctx) => _tenants[key] = ctx; + public bool RemoveTenant(string key) => _tenants.TryRemove(key, out _); + + public void MapDomain(string domain, string tenantKey) => _domainToTenant[domain.Trim().ToLowerInvariant()] = tenantKey; + public bool UnmapDomain(string domain) => _domainToTenant.TryRemove(domain.Trim().ToLowerInvariant(), out _); + + public void AddBaseDomain(string baseDomain) => _baseDomains[baseDomain.Trim().ToLowerInvariant()] = 1; + public bool RemoveBaseDomain(string baseDomain) => _baseDomains.TryRemove(baseDomain.Trim().ToLowerInvariant(), out _); +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Tenancy/TenantMapLoader.cs b/AMREZ.EOP.Infrastructures/Tenancy/TenantMapLoader.cs new file mode 100644 index 0000000..d2027d7 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Tenancy/TenantMapLoader.cs @@ -0,0 +1,83 @@ +using AMREZ.EOP.Domain.Entities.Tenancy; +using AMREZ.EOP.Domain.Shared.Tenancy; +using AMREZ.EOP.Infrastructures.Data; +using AMREZ.EOP.Infrastructures.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace AMREZ.EOP.Infrastructures.Tenancy; + + /// + /// โหลดค่า Tenants / Domains ที่ IsActive=true จาก meta.* ใส่ TenantMap ตอนสตาร์ต + /// และ bootstrap 'public' ถ้ายังไม่มี + /// + public sealed class TenantMapLoader : IHostedService + { + private readonly IServiceProvider _sp; + public TenantMapLoader(IServiceProvider sp) => _sp = sp; + + public async Task StartAsync(CancellationToken ct) + { + using var scope = _sp.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var map = scope.ServiceProvider.GetRequiredService(); + var opt = scope.ServiceProvider.GetRequiredService>().Value; + + // 1) Load tenants (active เท่านั้น) + var tenants = await db.Set() + .AsNoTracking() + .Where(x => x.IsActive) + .ToListAsync(ct); + + foreach (var t in tenants) + { + map.UpsertTenant( + t.TenantKey, + new TenantContext + { + Id = t.TenantKey, + Schema = string.IsNullOrWhiteSpace(t.Schema) ? t.TenantKey : t.Schema, + ConnectionString = string.IsNullOrWhiteSpace(t.ConnectionString) ? null : t.ConnectionString, + Mode = t.Mode + } + ); + } + + // 2) Load domains (active เท่านั้น) + var domains = await db.Set() + .AsNoTracking() + .Where(d => d.IsActive) + .ToListAsync(ct); + + foreach (var d in domains) + { + if (d.IsPlatformBaseDomain) + { + map.AddBaseDomain(d.Domain); + } + else if (!string.IsNullOrWhiteSpace(d.TenantKey)) + { + map.MapDomain(d.Domain, d.TenantKey!); + } + } + + // 3) Bootstrap 'public' ถ้ายังไม่มี (กัน chicken-and-egg) + if (!map.TryGetBySlug("public", out _)) + { + map.UpsertTenant( + "public", + new TenantContext + { + Id = "public", + Schema = "public", + ConnectionString = string.IsNullOrWhiteSpace(opt.DefaultConnection) ? null : opt.DefaultConnection, + Mode = TenantMode.Rls + } + ); + } + } + + public Task StopAsync(CancellationToken ct) => Task.CompletedTask; + } \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Tenancy/TenantProvisioner.cs b/AMREZ.EOP.Infrastructures/Tenancy/TenantProvisioner.cs new file mode 100644 index 0000000..cfc70aa --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Tenancy/TenantProvisioner.cs @@ -0,0 +1,39 @@ +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; +using AMREZ.EOP.Infrastructures.Data; +using AMREZ.EOP.Infrastructures.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace AMREZ.EOP.Infrastructures.Tenancy; + +public sealed class TenantProvisioner : ITenantProvisioner +{ + private readonly TenantMap _map; + private readonly ITenantDbContextFactory _factory; + private readonly IOptions _conn; + + public TenantProvisioner(TenantMap map, ITenantDbContextFactory factory, IOptions conn) + { _map = map; _factory = factory; _conn = conn; } + + public async Task ProvisionAsync(string tenantKey, CancellationToken ct = default) + { + if (!_map.TryGetBySlug(tenantKey, out var ctx)) + { + ctx = new TenantContext + { + Id = tenantKey, + Schema = tenantKey, + ConnectionString = _conn.Value.DefaultConnection, + Mode = AMREZ.EOP.Domain.Shared.Tenancy.TenantMode.Rls + }; + _map.UpsertTenant(tenantKey, (TenantContext)ctx); + } + + if (_conn.Value.UseSchemaPerTenant) + { + await using var db = _factory.Create(ctx); + await db.Database.MigrateAsync(ct); // idempotent + } + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/Tenancy/TenantRlsInterceptor.cs b/AMREZ.EOP.Infrastructures/Tenancy/TenantRlsInterceptor.cs new file mode 100644 index 0000000..f53ee36 --- /dev/null +++ b/AMREZ.EOP.Infrastructures/Tenancy/TenantRlsInterceptor.cs @@ -0,0 +1,27 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace AMREZ.EOP.Infrastructures.Tenancy; + +public sealed class TenantRlsInterceptor : DbConnectionInterceptor +{ + private string _tenant = ""; + public void Configure(string tenantId) => _tenant = tenantId?.Trim() ?? ""; + + public override async Task ConnectionOpenedAsync( + DbConnection connection, + ConnectionEndEventData eventData, + CancellationToken cancellationToken = default) + { + // ตั้งแค่ RLS session param เท่านั้น + if (!string.IsNullOrWhiteSpace(_tenant)) + { + await using var cmd = connection.CreateCommand(); + var v = _tenant.Replace("'", "''"); + cmd.CommandText = $"SET LOCAL app.tenant_id = '{v}';"; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + await base.ConnectionOpenedAsync(connection, eventData, cancellationToken); + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/UnitOfWork/EFUnitOfWork.cs b/AMREZ.EOP.Infrastructures/UnitOfWork/EFUnitOfWork.cs new file mode 100644 index 0000000..c3c8cdc --- /dev/null +++ b/AMREZ.EOP.Infrastructures/UnitOfWork/EFUnitOfWork.cs @@ -0,0 +1,119 @@ +using System.Data; +using System.Text.Json; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Storage; +using AMREZ.EOP.Domain.Entities.Authentications; +using AMREZ.EOP.Domain.Shared._Users; +using AMREZ.EOP.Infrastructures.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using StackExchange.Redis; + +namespace AMREZ.EOP.Infrastructures.UnitOfWork; + +public sealed class EFUnitOfWork : IUnitOfWork, IAsyncDisposable +{ + private readonly IDbScope _scope; + private readonly ITenantDbContextFactory _factory; + private readonly IConnectionMultiplexer? _redis; + + private AppDbContext? _db; + private IDbContextTransaction? _tx; + private ITenantContext? _tenant; + + public string Backend => "ef"; + + public EFUnitOfWork( + IDbScope scope, + ITenantDbContextFactory factory, + IConnectionMultiplexer? redis = null) + { + _scope = scope; + _factory = factory; + _redis = redis; // optional; null ได้ + } + + public async Task BeginAsync(ITenantContext tenant, IsolationLevel isolation = IsolationLevel.ReadCommitted, CancellationToken ct = default) + { + if (_db is not null) return; + _tenant = tenant ?? throw new ArgumentNullException(nameof(tenant)); + + _scope.EnsureForTenant(tenant); + _db = _scope.Get(); + _tx = await _db.Database.BeginTransactionAsync(isolation, ct); + } + + public async Task CommitAsync(CancellationToken ct = default) + { + if (_db is null) return; // ยังไม่ Begin + + // track entities ที่เปลี่ยน (เพื่อ invalidate cache แบบแม่นขึ้น) + var changedUsers = _db.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .Select(e => e.Entity) + .ToList(); + + var changedIdentities = _db.ChangeTracker.Entries() + .Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .Select(e => e.Entity) + .ToList(); + + await _db.SaveChangesAsync(ct); + + if (_tx is not null) + await _tx.CommitAsync(ct); + + // optional: invalidate/refresh Redis (ล่มก็ไม่พัง UoW) + if (_redis is not null && _tenant is not null && (changedUsers.Count > 0 || changedIdentities.Count > 0)) + { + var r = _redis.GetDatabase(); + var tenantId = _tenant.Id; + var tasks = new List(); + + foreach (var u in changedUsers) + { + var keyId = $"eop:{tenantId}:user:id:{u.Id:N}"; + tasks.Add(r.KeyDeleteAsync(keyId)); + + // refresh cache (ถ้าต้องการ) + var payload = JsonSerializer.Serialize(u); + var ttl = TimeSpan.FromMinutes(5); + tasks.Add(r.StringSetAsync(keyId, payload, ttl)); + } + + foreach (var i in changedIdentities.Where(i => i.Type == IdentityType.Email)) + { + var k = $"eop:{tenantId}:user:identity:email:{i.Identifier}"; + tasks.Add(r.KeyDeleteAsync(k)); + } + + try { await Task.WhenAll(tasks); } catch { /* swallow */ } + } + + await DisposeAsync(); + } + + public async Task RollbackAsync(CancellationToken ct = default) + { + try + { + if (_tx is not null) + await _tx.RollbackAsync(ct); + } + finally + { + await DisposeAsync(); + } + } + + public ValueTask DisposeAsync() + { + try { _tx?.Dispose(); } catch { /* ignore */ } + try { _db?.Dispose(); } catch { /* ignore */ } + + _tx = null; _db = null; _tenant = null; + + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/UnitOfWork/RedisUnitOfWork.cs b/AMREZ.EOP.Infrastructures/UnitOfWork/RedisUnitOfWork.cs new file mode 100644 index 0000000..df8620b --- /dev/null +++ b/AMREZ.EOP.Infrastructures/UnitOfWork/RedisUnitOfWork.cs @@ -0,0 +1,37 @@ +using System.Data; +using AMREZ.EOP.Abstractions.Applications.Tenancy; +using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Storage; +using StackExchange.Redis; + +namespace AMREZ.EOP.Infrastructures.UnitOfWork; + +public sealed class RedisUnitOfWork : IUnitOfWork +{ + private readonly IDbScope _scope; + private ITransaction? _tx; + + public RedisUnitOfWork(IDbScope scope) => _scope = scope; + public string Backend => "redis"; + + public Task BeginAsync(ITenantContext tenant, IsolationLevel isolation = IsolationLevel.ReadCommitted, CancellationToken ct = default) + { + _scope.EnsureForTenant(tenant); + var db = _scope.Get(); + _tx = db.CreateTransaction(); + return Task.CompletedTask; + } + + public async Task CommitAsync(CancellationToken ct = default) + { + if (_tx != null) await _tx.ExecuteAsync(); + } + + public Task RollbackAsync(CancellationToken ct = default) + { + _tx = null; // drop + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} \ No newline at end of file diff --git a/AMREZ.EOP.sln b/AMREZ.EOP.sln new file mode 100644 index 0000000..99ffefd --- /dev/null +++ b/AMREZ.EOP.sln @@ -0,0 +1,46 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMREZ.EOP.API", "AMREZ.EOP.API\AMREZ.EOP.API.csproj", "{4172927E-2481-416B-A990-052B444F7357}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMREZ.EOP.Domain", "AMREZ.EOP.Domain\AMREZ.EOP.Domain.csproj", "{A869AE4C-0FA4-4AD8-8E0B-690FBE54D08E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMREZ.EOP.Application", "AMREZ.EOP.Application\AMREZ.EOP.Application.csproj", "{810CBCF5-750B-4361-8BA0-C77896CD05A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMREZ.EOP.Abstractions", "AMREZ.EOP.Abstractions\AMREZ.EOP.Abstractions.csproj", "{5972909B-8A5C-43BE-B7D7-099112B8A15D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMREZ.EOP.Infrastructures", "AMREZ.EOP.Infrastructures\AMREZ.EOP.Infrastructures.csproj", "{2C5A4C08-7960-4E94-8CA9-C2195EDC4357}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AMREZ.EOP.Contracts", "AMREZ.EOP.Contracts\AMREZ.EOP.Contracts.csproj", "{4B18111D-0F5F-4A80-9431-9E54AFDFC31A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4172927E-2481-416B-A990-052B444F7357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4172927E-2481-416B-A990-052B444F7357}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4172927E-2481-416B-A990-052B444F7357}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4172927E-2481-416B-A990-052B444F7357}.Release|Any CPU.Build.0 = Release|Any CPU + {A869AE4C-0FA4-4AD8-8E0B-690FBE54D08E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A869AE4C-0FA4-4AD8-8E0B-690FBE54D08E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A869AE4C-0FA4-4AD8-8E0B-690FBE54D08E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A869AE4C-0FA4-4AD8-8E0B-690FBE54D08E}.Release|Any CPU.Build.0 = Release|Any CPU + {810CBCF5-750B-4361-8BA0-C77896CD05A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {810CBCF5-750B-4361-8BA0-C77896CD05A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {810CBCF5-750B-4361-8BA0-C77896CD05A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {810CBCF5-750B-4361-8BA0-C77896CD05A3}.Release|Any CPU.Build.0 = Release|Any CPU + {5972909B-8A5C-43BE-B7D7-099112B8A15D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5972909B-8A5C-43BE-B7D7-099112B8A15D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5972909B-8A5C-43BE-B7D7-099112B8A15D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5972909B-8A5C-43BE-B7D7-099112B8A15D}.Release|Any CPU.Build.0 = Release|Any CPU + {2C5A4C08-7960-4E94-8CA9-C2195EDC4357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C5A4C08-7960-4E94-8CA9-C2195EDC4357}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C5A4C08-7960-4E94-8CA9-C2195EDC4357}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C5A4C08-7960-4E94-8CA9-C2195EDC4357}.Release|Any CPU.Build.0 = Release|Any CPU + {4B18111D-0F5F-4A80-9431-9E54AFDFC31A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B18111D-0F5F-4A80-9431-9E54AFDFC31A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B18111D-0F5F-4A80-9431-9E54AFDFC31A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B18111D-0F5F-4A80-9431-9E54AFDFC31A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e4091d1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# ---------- Build ---------- +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# copy ไฟล์โครงการทีละตัวเพื่อ cache restore +COPY AMREZ.EOP.sln ./ +COPY AMREZ.EOP.API/AMREZ.EOP.API.csproj AMREZ.EOP.API/ +COPY AMREZ.EOP.Application/AMREZ.EOP.Application.csproj AMREZ.EOP.Application/ +COPY AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj AMREZ.EOP.Abstractions/ +COPY AMREZ.EOP.Contracts/AMREZ.EOP.Contracts.csproj AMREZ.EOP.Contracts/ +COPY AMREZ.EOP.Domain/AMREZ.EOP.Domain.csproj AMREZ.EOP.Domain/ +COPY AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj AMREZ.EOP.Infrastructures/ + +RUN dotnet restore AMREZ.EOP.sln + +# ค่อย copy ทั้ง repo แล้ว publish (ไม่ต้อง restore ซ้ำ) +COPY . . +RUN dotnet publish AMREZ.EOP.API/AMREZ.EOP.API.csproj -c Release -o /app/publish /p:UseAppHost=false --no-restore + +# ---------- Runtime ---------- +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app +# ให้ชัดเจน: Kestrel ฟังที่ 8080 +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 +EXPOSE 8080 + +COPY --from=build /app/publish ./ +ENTRYPOINT ["dotnet", "AMREZ.EOP.API.dll"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..2c654ed --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,86 @@ +pipeline { + agent any + + options { + ansiColor('xterm'); timestamps(); disableConcurrentBuilds() + timeout(time: 25, unit: 'MINUTES') + } + + /* ให้ Gitea ยิง webhook แล้ว build ได้เลย */ + triggers { + giteaPush() + } + + environment { + REGISTRY = 'registry.aetherframe.tech' + IMAGE = 'simulationable/eop-services-api' // แนะนำเป็นตัวพิมพ์เล็กทั้งหมด + APP_PORT = '8080' // Kestrel ใน container ฟัง 8080 + HOST_PORT = '5002' // พอร์ตฝั่ง host ที่ Nginx จะ proxy เข้า + } + + stages { + stage('Checkout') { + steps { checkout scm } + } + + stage('Unit Tests (optional)') { + when { expression { fileExists('AMREZ.EOP.sln') } } + steps { + sh ''' + docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 \ + bash -lc "dotnet test --configuration Release --nologo --logger trx; true" + ''' + } + } + + stage('Docker Build') { + steps { + sh 'docker version' + script { + /* รองรับทั้ง Multibranch (BRANCH_NAME) และ Pipeline เดี่ยว (GIT_BRANCH) */ + def branch = (env.BRANCH_NAME ?: env.GIT_BRANCH ?: 'main').replaceFirst(/^origin\\//,'') + def tag = "${branch}-${env.BUILD_NUMBER}" + sh """ + docker build -t ${REGISTRY}/${IMAGE}:${tag} . + docker tag ${REGISTRY}/${IMAGE}:${tag} ${REGISTRY}/${IMAGE}:latest + """ + env.IMAGE_TAG = tag + } + } + } + + stage('Docker Push') { + steps { + withCredentials([usernamePassword(credentialsId: 'registry-basic', + usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS')]) { + sh """ + echo "\$REG_PASS" | docker login ${REGISTRY} -u "\$REG_USER" --password-stdin + docker push ${REGISTRY}/${IMAGE}:${IMAGE_TAG} + docker push ${REGISTRY}/${IMAGE}:latest + docker logout ${REGISTRY} || true + """ + } + } + } + + stage('Deploy (same host)') { + when { branch 'main' } + steps { + sh """ + set -eux + docker rm -f eop-services-api || true + docker run -d --name eop-services-api \\ + -p 127.0.0.1:${HOST_PORT}:${APP_PORT} \\ + -e ASPNETCORE_URLS=http://+:${APP_PORT} \\ + --restart=always \\ + ${REGISTRY}/${IMAGE}:latest + """ + } + } + } + + post { + success { echo "Deployed at http://127.0.0.1:${HOST_PORT} (จะเข้าผ่าน https หลังตั้ง Nginx)" } + always { cleanWs() } + } +} diff --git a/appspec.yml b/appspec.yml new file mode 100644 index 0000000..40e3944 --- /dev/null +++ b/appspec.yml @@ -0,0 +1,19 @@ +version: 0.0 +os: linux +files: + - source: / + destination: /home/ec2-user/app + +hooks: + BeforeInstall: + - location: scripts/setup_tunnel.sh + timeout: 600 + runas: root + AfterInstall: + - location: scripts/deploy.sh + timeout: 600 + runas: ec2-user + ValidateService: + - location: scripts/verify.sh + timeout: 120 + runas: ec2-user diff --git a/buildspec.yml b/buildspec.yml new file mode 100644 index 0000000..a34ce73 --- /dev/null +++ b/buildspec.yml @@ -0,0 +1,26 @@ +version: 0.2 +env: + variables: + AWS_REGION: ap-southeast-7 + ECR_REPO_URI: 804770683810.dkr.ecr.ap-southeast-7.amazonaws.com/amrez/eop-services + git-credential-helper: yes + +phases: + pre_build: + commands: + - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ECR_REPO_URI + - COMMIT_SHA=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c1-7) + - IMAGE_TAG=${COMMIT_SHA:-latest} + build: + commands: + - docker build -t $ECR_REPO_URI:$IMAGE_TAG -t $ECR_REPO_URI:latest . + post_build: + commands: + - docker push $ECR_REPO_URI:$IMAGE_TAG + - docker push $ECR_REPO_URI:latest + - printf '{"image":"%s","tag":"%s"}\n' "$ECR_REPO_URI" "$IMAGE_TAG" > image_detail.json +artifacts: + files: + - appspec.yml + - scripts/**/* + - image_detail.json diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..9a91dba --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail + +APP_NAME="eop-api" +AWS_REGION="ap-southeast-7" +ECR_REPO_URI="804770683810.dkr.ecr.ap-southeast-7.amazonaws.com/amrez/eop-services" +SSM_PREFIX="/amrez/eop" + +need() { + local v + v=$(aws ssm get-parameter --with-decryption --region "$AWS_REGION" --name "${SSM_PREFIX}/$1" \ + --query "Parameter.Value" --output text 2>/dev/null || true) + if [ -z "${v:-}" ] || [ "$v" == "None" ]; then + echo "ERROR: missing SSM parameter: ${SSM_PREFIX}/$1" >&2; exit 1 + fi + echo "$v" +} + +DC=$( need "Connections__DefaultConnection") # Host=127.0.0.1;Port=15432;... +UST=$( need "Connections__UseSchemaPerTenant") +SB=$( need "Connections__StorageBackend") +RC=$( need "Connections__RedisConnection") +RDB=$( need "Connections__RedisDb") + +aws ecr get-login-password --region "$AWS_REGION" | docker login --username AWS --password-stdin "$ECR_REPO_URI" +docker pull "$ECR_REPO_URI:latest" + +if docker ps -aq -f name=^/${APP_NAME}$ >/dev/null; then + docker stop "$APP_NAME" || true + docker rm "$APP_NAME" || true +fi + +# DB tunnel ต้องพร้อม (15432) +if ! ss -lnt | grep -q ":15432 "; then + echo "ERROR: tunnel 127.0.0.1:15432 is not listening" >&2 + exit 1 +fi + +# host network ⇒ 127.0.0.1 ใน container = ของโฮสต์ +docker run -d --name "$APP_NAME" \ + --network=host \ + --restart=always \ + -e ASPNETCORE_ENVIRONMENT=Production \ + -e ASPNETCORE_URLS=http://0.0.0.0:80 \ + -e "Connections__DefaultConnection=${DC}" \ + -e "Connections__UseSchemaPerTenant=${UST}" \ + -e "Connections__StorageBackend=${SB}" \ + -e "Connections__RedisConnection=${RC}" \ + -e "Connections__RedisDb=${RDB}" \ + "$ECR_REPO_URI:latest" + +echo "Deployed $APP_NAME using $ECR_REPO_URI:latest" diff --git a/scripts/setup_tunnel.sh b/scripts/setup_tunnel.sh new file mode 100644 index 0000000..88a5417 --- /dev/null +++ b/scripts/setup_tunnel.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -euo pipefail + +AWS_REGION="ap-southeast-7" +SSM_PREFIX="/amrez/eop" + +need() { + aws ssm get-parameter --with-decryption --region "$AWS_REGION" --name "${SSM_PREFIX}/$1" \ + --query "Parameter.Value" --output text +} + +# autossh +if ! command -v autossh >/dev/null 2>&1; then + dnf install -y autossh >/dev/null +fi + +SSH_USER=$(need "tunnel/ssh_user") +BASTION_HOST=$(need "tunnel/bastion_host") +DB_HOST=$(need "tunnel/db_host") +DB_PORT=$(need "tunnel/db_port") +LOCAL_PORT=$(need "tunnel/local_port") + +install -d -m 700 -o root -g root /opt/eop-tunnel +aws ssm get-parameter --with-decryption --region "$AWS_REGION" \ + --name "${SSM_PREFIX}/tunnel/private_key" --query "Parameter.Value" --output text > /opt/eop-tunnel/id_rsa +chmod 600 /opt/eop-tunnel/id_rsa + +cat >/etc/systemd/system/eop-db-tunnel.service < ${DB_HOST}:${DB_PORT} via ${BASTION_HOST}) +After=network-online.target +Wants=network-online.target +[Service] +Type=simple +User=root +Restart=always +RestartSec=5 +ExecStartPre=/usr/bin/bash -lc 'ss -lnt | grep -q ":${LOCAL_PORT} " && killall -q -w ssh || true' +ExecStart=/usr/bin/autossh -M 0 -N \ + -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnFailure=yes -o ExitOnForwardFailure=yes \ + -o StrictHostKeyChecking=no -i /opt/eop-tunnel/id_rsa \ + -L 127.0.0.1:${LOCAL_PORT}:${DB_HOST}:${DB_PORT} ${SSH_USER}@${BASTION_HOST} +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable --now eop-db-tunnel.service + +# wait ready +for i in {1..30}; do + ss -lnt | grep -q ":${LOCAL_PORT} " && exit 0 + sleep 1 +done +echo "Tunnel not ready on 127.0.0.1:${LOCAL_PORT}" >&2 +exit 1 diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..69cca72 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +APP_NAME="eop-api" +docker ps --filter "name=^/${APP_NAME}$" --filter "status=running" --format '{{.Names}}' | grep -q "^${APP_NAME}$" +ss -lnt | grep -q ":80 "