Add Master Data

This commit is contained in:
Thanakarn Klangkasame
2025-10-10 16:22:06 +07:00
parent ad0d9e41ba
commit d4ab1cb592
55 changed files with 16058 additions and 197 deletions

View File

@@ -1,6 +1,8 @@
using System.Security.Claims; using System.Security.Claims;
using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
using AMREZ.EOP.Application.UseCases.Authentications;
using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity; using AMREZ.EOP.Contracts.DTOs.Authentications.AddEmailIdentity;
using AMREZ.EOP.Contracts.DTOs.Authentications.AssignRole;
using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword; using AMREZ.EOP.Contracts.DTOs.Authentications.ChangePassword;
using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa; using AMREZ.EOP.Contracts.DTOs.Authentications.DisableMfa;
using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp; using AMREZ.EOP.Contracts.DTOs.Authentications.EnableTotp;
@@ -10,6 +12,8 @@ using AMREZ.EOP.Contracts.DTOs.Authentications.Logout;
using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll; using AMREZ.EOP.Contracts.DTOs.Authentications.LogoutAll;
using AMREZ.EOP.Contracts.DTOs.Authentications.Refresh; using AMREZ.EOP.Contracts.DTOs.Authentications.Refresh;
using AMREZ.EOP.Contracts.DTOs.Authentications.Register; using AMREZ.EOP.Contracts.DTOs.Authentications.Register;
using AMREZ.EOP.Contracts.DTOs.Authentications.Role;
using AMREZ.EOP.Contracts.DTOs.Authentications.UnassignRole;
using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail; using AMREZ.EOP.Contracts.DTOs.Authentications.VerifyEmail;
using AMREZ.EOP.Domain.Shared.Contracts; using AMREZ.EOP.Domain.Shared.Contracts;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
@@ -37,6 +41,7 @@ public class AuthenticationController : ControllerBase
private readonly IIssueTokenPairUseCase _issueTokens; private readonly IIssueTokenPairUseCase _issueTokens;
private readonly IRefreshUseCase _refresh; private readonly IRefreshUseCase _refresh;
public AuthenticationController( public AuthenticationController(
ILoginUseCase login, ILoginUseCase login,
IRegisterUseCase register, IRegisterUseCase register,
@@ -74,9 +79,21 @@ public class AuthenticationController : ControllerBase
new(ClaimTypes.NameIdentifier, res.UserId.ToString()), new(ClaimTypes.NameIdentifier, res.UserId.ToString()),
new(ClaimTypes.Name, res.Email), new(ClaimTypes.Name, res.Email),
new(ClaimTypes.Email, res.Email), new(ClaimTypes.Email, res.Email),
new("tenant", res.TenantKey) new("tenant", res.TenantKey),
new("tenantId", res.TenantId.ToString())
}; };
var roles = (res.Roles ?? Array.Empty<string>())
.Where(r => !string.IsNullOrWhiteSpace(r))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
foreach (var r in roles)
claims.Add(new Claim(ClaimTypes.Role, r));
if (roles.Length > 0)
claims.Add(new Claim("roles_csv", string.Join(",", roles)));
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthPolicies.Scheme)); var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, AuthPolicies.Scheme));
await HttpContext.SignInAsync(AuthPolicies.Scheme, principal); await HttpContext.SignInAsync(AuthPolicies.Scheme, principal);
@@ -85,7 +102,8 @@ public class AuthenticationController : ControllerBase
UserId = res.UserId, UserId = res.UserId,
TenantId = res.TenantId, TenantId = res.TenantId,
Tenant = res.TenantKey, Tenant = res.TenantKey,
Email = res.Email Email = res.Email,
Roles = roles
}, ct); }, ct);
if (!string.IsNullOrWhiteSpace(tokenPair.RefreshToken)) if (!string.IsNullOrWhiteSpace(tokenPair.RefreshToken))
@@ -105,6 +123,7 @@ public class AuthenticationController : ControllerBase
return Ok(new return Ok(new
{ {
user = res, user = res,
roles,
access_token = tokenPair.AccessToken, access_token = tokenPair.AccessToken,
token_type = "Bearer", token_type = "Bearer",
expires_at = tokenPair.AccessExpiresAt expires_at = tokenPair.AccessExpiresAt
@@ -164,6 +183,8 @@ public class AuthenticationController : ControllerBase
return NoContent(); return NoContent();
} }
[HttpPost("logout")] [HttpPost("logout")]
public async Task<IActionResult> Logout() public async Task<IActionResult> Logout()
{ {

View File

@@ -10,6 +10,7 @@ public interface IUserRepository
Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default); Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default);
Task<bool> EmailExistsAsync(string email, CancellationToken ct = default); Task<bool> EmailExistsAsync(string email, CancellationToken ct = default);
Task AddAsync(User user, CancellationToken ct = default); Task AddAsync(User user, CancellationToken ct = default);
Task<string[]> GetRoleCodesByUserIdAsync(Guid userId, Guid tenantId, CancellationToken ct = default);
Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default); 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 VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default);

View File

@@ -32,52 +32,71 @@ public sealed class IssueTokenPairUseCase : IIssueTokenPairUseCase
} }
public async Task<IssueTokenPairResponse> ExecuteAsync(IssueTokenPairRequest request, CancellationToken ct = default) public async Task<IssueTokenPairResponse> ExecuteAsync(IssueTokenPairRequest request, CancellationToken ct = default)
{
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext");
var tenantId = request.TenantId;
// ---- สร้าง/บันทึก refresh session ----
var refreshRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
var refreshHash = Sha256(refreshRaw);
var now = DateTimeOffset.UtcNow;
var session = await _users.CreateSessionAsync(new UserSession
{ {
var http = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext"); Id = Guid.NewGuid(),
TenantId = tenantId,
UserId = request.UserId,
RefreshTokenHash = refreshHash,
IssuedAt = now,
ExpiresAt = now.AddDays(RefreshDays),
DeviceId = http.Request.Headers["X-Device-Id"].FirstOrDefault(),
UserAgent = http.Request.Headers["User-Agent"].FirstOrDefault(),
IpAddress = http.Connection.RemoteIpAddress?.ToString()
}, ct);
var tenantId = request.TenantId; // ---- เวอร์ชัน/สแตมป์ความปลอดภัย ----
var tv = await _users.GetTenantTokenVersionAsync(tenantId, ct);
var sstamp = await _users.GetUserSecurityStampAsync(request.UserId, ct) ?? string.Empty;
var refreshRaw = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); // ---- เตรียม claims พื้นฐาน ----
var refreshHash = Sha256(refreshRaw); var claims = new List<Claim>
var now = DateTimeOffset.UtcNow; {
new("sub", request.UserId.ToString()),
new("email", request.Email),
new("tenant", request.Tenant), // tenantKey ที่ FE ใช้
new("tenant_id", tenantId.ToString()),
new("sid", session.Id.ToString()),
new("jti", Guid.NewGuid().ToString("N")),
new("tv", tv),
new("sstamp", sstamp),
new("iat", now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
};
var session = await _users.CreateSessionAsync(new UserSession string[] roles = request.Roles ?? Array.Empty<string>();
{ if (roles.Length == 0)
Id = Guid.NewGuid(), {
TenantId = tenantId, roles = await _users.GetRoleCodesByUserIdAsync(request.UserId, tenantId, ct);
UserId = request.UserId,
RefreshTokenHash = refreshHash,
IssuedAt = now,
ExpiresAt = now.AddDays(RefreshDays),
DeviceId = http.Request.Headers["X-Device-Id"].FirstOrDefault(),
UserAgent = http.Request.Headers["User-Agent"].FirstOrDefault(),
IpAddress = http.Connection.RemoteIpAddress?.ToString()
}, ct);
var tv = await _users.GetTenantTokenVersionAsync(tenantId, ct);
var sstamp = await _users.GetUserSecurityStampAsync(request.UserId, ct) ?? string.Empty;
var claims = new List<Claim>
{
new("sub", request.UserId.ToString()),
new("tenant_id", tenantId.ToString()),
new("sid", session.Id.ToString()),
new("jti", Guid.NewGuid().ToString("N")),
new("tv", tv),
new("sstamp", sstamp)
};
var (access, accessExp) = _jwt.CreateAccessToken(claims);
return new IssueTokenPairResponse
{
AccessToken = access,
AccessExpiresAt = accessExp,
RefreshToken = refreshRaw, // ส่ง raw กลับให้ client
RefreshExpiresAt = session.ExpiresAt
};
} }
foreach (var r in roles.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct(StringComparer.OrdinalIgnoreCase))
{
claims.Add(new Claim("role", r));
claims.Add(new Claim(ClaimTypes.Role, r));
}
// ---- ออก access token ----
var (access, accessExp) = _jwt.CreateAccessToken(claims);
return new IssueTokenPairResponse
{
AccessToken = access,
AccessExpiresAt = accessExp,
RefreshToken = refreshRaw,
RefreshExpiresAt = session.ExpiresAt
};
}
private static string Sha256(string raw) private static string Sha256(string raw)
{ {
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw));

View File

@@ -5,6 +5,7 @@ using AMREZ.EOP.Abstractions.Infrastructures.Common;
using AMREZ.EOP.Abstractions.Infrastructures.Repositories; using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
using AMREZ.EOP.Abstractions.Security; using AMREZ.EOP.Abstractions.Security;
using AMREZ.EOP.Contracts.DTOs.Authentications.Login; using AMREZ.EOP.Contracts.DTOs.Authentications.Login;
using AMREZ.EOP.Domain.Entities.Authentications;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
namespace AMREZ.EOP.Application.UseCases.Authentications namespace AMREZ.EOP.Application.UseCases.Authentications
@@ -60,9 +61,15 @@ namespace AMREZ.EOP.Application.UseCases.Authentications
return null; return null;
} }
var roles = (user.UserRoles ?? Enumerable.Empty<UserRole>())
.Where(ur => ur.Role != null /*&& ur.Role.TenantId == user.TenantId*/) // ถ้า Role เป็น per-tenant ค่อยกรอง
.Select(ur => ur.Role!.Code)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
await _uow.CommitAsync(ct); await _uow.CommitAsync(ct);
return new LoginResponse(user.Id, user.TenantId, email, tenantKey); return new LoginResponse(user.Id, user.TenantId, email, tenantKey, roles);
} }
catch catch
{ {

View File

@@ -0,0 +1,3 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.AssignRole;
public sealed class AssignRoleRequest(Guid UserId, Guid TenantId, string RoleCode);

View File

@@ -0,0 +1,3 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.AssignRole;
public sealed class AssignRoleResponse(string[] Roles);

View File

@@ -6,4 +6,5 @@ public sealed class IssueTokenPairRequest
public Guid TenantId { get; init; } = default!; public Guid TenantId { get; init; } = default!;
public string Tenant { get; init; } = default!; public string Tenant { get; init; } = default!;
public string Email { get; init; } = default!; public string Email { get; init; } = default!;
public string[]? Roles { get; init; } = default;
} }

View File

@@ -4,5 +4,6 @@ public sealed record LoginResponse(
Guid UserId, Guid UserId,
Guid TenantId, Guid TenantId,
string Email, string Email,
string TenantKey string TenantKey,
string[] Roles
); );

View File

@@ -0,0 +1,3 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.Role;
public sealed record RoleChange(string RoleCode);

View File

@@ -0,0 +1,3 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.UnassignRole;
public sealed class UnassignRoleRequest(Guid UserId, Guid TenantId, string RoleCode);

View File

@@ -0,0 +1,3 @@
namespace AMREZ.EOP.Contracts.DTOs.Authentications.UnassignRole;
public sealed class UnassignRoleResponse(string[] Roles);

View File

@@ -4,7 +4,7 @@ namespace AMREZ.EOP.Domain.Entities.Authentications;
public sealed class Role : BaseEntity public sealed class Role : BaseEntity
{ {
public string Code { get; set; } = default!; // system code, unique per tenant public string Code { get; set; } = default!;
public string Name { get; set; } = default!; public string Name { get; set; } = default!;
public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>(); public ICollection<UserRole> UserRoles { get; set; } = new List<UserRole>();

View File

@@ -0,0 +1,25 @@
using AMREZ.EOP.Domain.Entities.Common;
namespace AMREZ.EOP.Domain.Entities.MasterData;
public abstract class MasterBase : BaseEntity
{
public string Scope { get; set; } = "global";
public string Code { get; set; } = null!;
public string Name { get; set; } = null!;
public Dictionary<string, string>? NameI18n { get; set; }
public Guid? OverridesGlobalId { get; set; }
public bool IsActive { get; set; } = true;
public bool IsSystem { get; set; } = false;
public Dictionary<string, object>? Meta { get; set; }
}
public abstract class MasterBlockBase : BaseEntity
{
public Guid GlobalId { get; set; }
}

View File

@@ -0,0 +1,22 @@
using AMREZ.EOP.Domain.Entities.Common;
namespace AMREZ.EOP.Domain.Entities.Customers;
public sealed class Address : BaseEntity
{
public string? Line1 { get; set; }
public string? Line2 { get; set; }
public string? Village { get; set; }
public string? Soi { get; set; }
public string? Road { get; set; }
public string? Subdistrict { get; set; }
public string? District { get; set; }
public string? Province { get; set; }
public string? PostalCode { get; set; }
public string CountryCode { get; set; } = "TH";
public bool Verified { get; set; }
public double? GeoLat { get; set; }
public double? GeoLng { get; set; }
public ICollection<CustomerAddress> CustomerLinks { get; set; } = new List<CustomerAddress>();
}

View File

@@ -0,0 +1,15 @@
using AMREZ.EOP.Domain.Entities.Common;
using AMREZ.EOP.Domain.Shared.Customer;
namespace AMREZ.EOP.Domain.Entities.Customers;
public sealed class CustomerAddress : BaseEntity
{
public Guid CustomerProfileId { get; set; }
public Guid AddressId { get; set; }
public AddressLabel Label { get; set; }
public bool IsDefault { get; set; }
public CustomerProfile Customer { get; set; } = default!;
public Address Address { get; set; } = default!;
}

View File

@@ -0,0 +1,15 @@
using AMREZ.EOP.Domain.Entities.Common;
using AMREZ.EOP.Domain.Shared.Customer;
namespace AMREZ.EOP.Domain.Entities.Customers;
public sealed class CustomerContact : BaseEntity
{
public Guid CustomerProfileId { get; set; }
public ContactType Type { get; set; }
public string Value { get; set; } = default!;
public bool IsPrimary { get; set; }
public bool IsVerified { get; set; }
public CustomerProfile Customer { get; set; } = default!;
}

View File

@@ -0,0 +1,18 @@
using AMREZ.EOP.Domain.Entities.Common;
using AMREZ.EOP.Domain.Shared.Customer;
namespace AMREZ.EOP.Domain.Entities.Customers;
public sealed class CustomerProfile : BaseEntity
{
public PartyType Type { get; set; }
public string DisplayName { get; set; } = default!;
public CustomerStatus Status { get; set; } = CustomerStatus.Active;
public PersonProfile? Person { get; set; }
public OrganizationProfile? Organization { get; set; }
public ICollection<CustomerContact> Contacts { get; set; } = new List<CustomerContact>();
public ICollection<CustomerAddress> Addresses { get; set; } = new List<CustomerAddress>();
public ICollection<CustomerTag> Tags { get; set; } = new List<CustomerTag>();
}

View File

@@ -0,0 +1,11 @@
using AMREZ.EOP.Domain.Entities.Common;
namespace AMREZ.EOP.Domain.Entities.Customers;
public sealed class CustomerTag : BaseEntity
{
public Guid CustomerProfileId { get; set; }
public string Tag { get; set; } = default!;
public CustomerProfile Customer { get; set; } = default!;
}

View File

@@ -0,0 +1,17 @@
using AMREZ.EOP.Domain.Entities.Common;
namespace AMREZ.EOP.Domain.Entities.Customers;
public sealed class OrganizationProfile : BaseEntity
{
public Guid CustomerProfileId { get; set; }
public string LegalNameTh { get; set; } = default!;
public string? LegalNameEn { get; set; }
public string? RegistrationNo { get; set; }
public string? TaxId13 { get; set; }
public string? BranchCode { get; set; }
public string? CompanyType { get; set; }
public CustomerProfile Customer { get; set; } = default!;
}

View File

@@ -0,0 +1,19 @@
using AMREZ.EOP.Domain.Entities.Common;
namespace AMREZ.EOP.Domain.Entities.Customers;
public sealed class PersonProfile : BaseEntity
{
public Guid CustomerProfileId { get; set; }
public string? Prefix { get; set; }
public string? FirstNameTh { get; set; }
public string? LastNameTh { get; set; }
public string? FirstNameEn { get; set; }
public string? LastNameEn { get; set; }
public DateTime? BirthDate { get; set; }
public string? NationalId { get; set; }
public string? PassportNo { get; set; }
public CustomerProfile Customer { get; set; } = default!;
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Allergen : MasterBase
{
}
public class AllergenBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,10 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Brand : MasterBase
{
public string? ExternalRef { get; set; }
}
public class BrandBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,26 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Category : MasterBase
{
public Guid? ParentId { get; set; }
public Category? Parent { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>();
public string? Path { get; set; }
public int SortOrder { get; set; } = 0;
}
public class CategoryBlock : MasterBlockBase
{
}
public class CategoryExt
{
public Guid CategoryId { get; set; }
public Category Category { get; set; } = null!;
public string? Slug { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public string? ImageUrl { get; set; }
public Dictionary<string, object>? ChannelVisibility { get; set; }
public Dictionary<string, object>? FacetSchema { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class ComplianceStatus : MasterBase
{
}
public class ComplianceStatusBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Country : MasterBase
{
}
public class CountryBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,10 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Currency : MasterBase
{
public byte Exponent { get; set; } = 2;
}
public class CurrencyBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class DocControlStatus : MasterBase
{
}
public class DocControlStatusBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class FuncTest : MasterBase
{
}
public class FuncTestBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class HazardClass : MasterBase
{
}
public class HazardClassBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Language : MasterBase
{
}
public class LanguageBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,10 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Manufacturer : MasterBase
{
public string? CountryCode { get; set; }
}
public class ManufacturerBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Market : MasterBase
{
}
public class MarketBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class PackingGroup : MasterBase
{
}
public class PackingGroupBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class QaStage : MasterBase
{
}
public class QaStageBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class QcStatus : MasterBase
{
}
public class QcStatusBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class RecallClass : MasterBase
{
}
public class RecallClassBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class RiskClass : MasterBase
{
}
public class RiskClassBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Route : MasterBase
{
}
public class RouteBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class RxSchedule : MasterBase
{
}
public class RxScheduleBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Species : MasterBase
{
}
public class SpeciesBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class StabilityStatus : MasterBase
{
}
public class StabilityStatusBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class SterilizationMethod : MasterBase
{
}
public class SterilizationMethodBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,11 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Uom : MasterBase
{
public string? BaseCode { get; set; }
public decimal? SiFactor { get; set; }
}
public class UomBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Entities.MasterData;
public class Vvm : MasterBase
{
}
public class VvmBlock : MasterBlockBase
{
}

View File

@@ -0,0 +1,9 @@
namespace AMREZ.EOP.Domain.Shared.Customer;
public enum AddressLabel
{
Billing,
Shipping,
Registered,
Other
}

View File

@@ -0,0 +1,11 @@
namespace AMREZ.EOP.Domain.Shared.Customer;
public enum ContactType
{
Email,
Phone,
Line,
Whatsapp,
WeChat,
Other
}

View File

@@ -0,0 +1,8 @@
namespace AMREZ.EOP.Domain.Shared.Customer;
public enum CustomerStatus
{
Active,
Inactive,
Blacklisted
}

View File

@@ -0,0 +1,7 @@
namespace AMREZ.EOP.Domain.Shared.Customer;
public enum PartyType
{
Person,
Organization
}

View File

@@ -1,15 +1,22 @@
using System.Text.Json;
using AMREZ.EOP.Domain.Entities.Authentications; using AMREZ.EOP.Domain.Entities.Authentications;
using AMREZ.EOP.Domain.Entities.Common; using AMREZ.EOP.Domain.Entities.Common;
using AMREZ.EOP.Domain.Entities.Customers;
using AMREZ.EOP.Domain.Entities.HumanResources; using AMREZ.EOP.Domain.Entities.HumanResources;
using AMREZ.EOP.Domain.Entities.MasterData;
using AMREZ.EOP.Domain.Entities.Tenancy; using AMREZ.EOP.Domain.Entities.Tenancy;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Route = AMREZ.EOP.Domain.Entities.MasterData.Route;
namespace AMREZ.EOP.Infrastructures.Data; namespace AMREZ.EOP.Infrastructures.Data;
public class AppDbContext : DbContext public class AppDbContext : DbContext
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
// ===== Auth ===== // ===== Auth =====
public DbSet<User> Users => Set<User>(); public DbSet<User> Users => Set<User>();
@@ -36,14 +43,170 @@ public class AppDbContext : DbContext
public DbSet<TenantConfig> Tenants => Set<TenantConfig>(); public DbSet<TenantConfig> Tenants => Set<TenantConfig>();
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>(); public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
// ===== Customers =====
public DbSet<CustomerProfile> CustomerProfiles => Set<CustomerProfile>();
public DbSet<PersonProfile> PersonProfiles => Set<PersonProfile>();
public DbSet<OrganizationProfile> OrganizationProfiles => Set<OrganizationProfile>();
public DbSet<Address> Addresses => Set<Address>();
public DbSet<CustomerAddress> CustomerAddresses => Set<CustomerAddress>();
public DbSet<CustomerContact> CustomerContacts => Set<CustomerContact>();
public DbSet<CustomerTag> CustomerTags => Set<CustomerTag>();
// ===== Master Data =====
public DbSet<Brand> Brands => Set<Brand>();
public DbSet<BrandBlock> BrandBlocks => Set<BrandBlock>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<CategoryBlock> CategoryBlocks => Set<CategoryBlock>();
public DbSet<CategoryExt> CategoryExts => Set<CategoryExt>();
public DbSet<Uom> Uoms => Set<Uom>();
public DbSet<UomBlock> UomBlocks => Set<UomBlock>();
public DbSet<Currency> Currencies => Set<Currency>();
public DbSet<CurrencyBlock> CurrencyBlocks => Set<CurrencyBlock>();
public DbSet<Country> Countries => Set<Country>();
public DbSet<CountryBlock> CountryBlocks => Set<CountryBlock>();
public DbSet<Manufacturer> Manufacturers => Set<Manufacturer>();
public DbSet<ManufacturerBlock> ManufacturerBlocks => Set<ManufacturerBlock>();
public DbSet<Market> Markets => Set<Market>();
public DbSet<MarketBlock> MarketBlocks => Set<MarketBlock>();
public DbSet<Language> Languages => Set<Language>();
public DbSet<LanguageBlock> LanguageBlocks => Set<LanguageBlock>();
public DbSet<ComplianceStatus> ComplianceStatuses => Set<ComplianceStatus>();
public DbSet<ComplianceStatusBlock> ComplianceStatusBlocks => Set<ComplianceStatusBlock>();
public DbSet<QaStage> QaStages => Set<QaStage>();
public DbSet<QaStageBlock> QaStageBlocks => Set<QaStageBlock>();
public DbSet<Route> Routes => Set<Route>();
public DbSet<RouteBlock> RouteBlocks => Set<RouteBlock>();
public DbSet<RxSchedule> RxSchedules => Set<RxSchedule>();
public DbSet<RxScheduleBlock> RxScheduleBlocks => Set<RxScheduleBlock>();
public DbSet<StabilityStatus> StabilityStatuses => Set<StabilityStatus>();
public DbSet<StabilityStatusBlock> StabilityStatusBlocks => Set<StabilityStatusBlock>();
public DbSet<RiskClass> RiskClasses => Set<RiskClass>();
public DbSet<RiskClassBlock> RiskClassBlocks => Set<RiskClassBlock>();
public DbSet<SterilizationMethod> SterilizationMethods => Set<SterilizationMethod>();
public DbSet<SterilizationMethodBlock> SterilizationMethodBlocks => Set<SterilizationMethodBlock>();
public DbSet<QcStatus> QcStatuses => Set<QcStatus>();
public DbSet<QcStatusBlock> QcStatusBlocks => Set<QcStatusBlock>();
public DbSet<DocControlStatus> DocControlStatuses => Set<DocControlStatus>();
public DbSet<DocControlStatusBlock> DocControlStatusBlocks => Set<DocControlStatusBlock>();
public DbSet<RecallClass> RecallClasses => Set<RecallClass>();
public DbSet<RecallClassBlock> RecallClassBlocks => Set<RecallClassBlock>();
public DbSet<PackingGroup> PackingGroups => Set<PackingGroup>();
public DbSet<PackingGroupBlock> PackingGroupBlocks => Set<PackingGroupBlock>();
public DbSet<Vvm> Vvms => Set<Vvm>();
public DbSet<VvmBlock> VvmBlocks => Set<VvmBlock>();
public DbSet<FuncTest> FuncTests => Set<FuncTest>();
public DbSet<FuncTestBlock> FuncTestBlocks => Set<FuncTestBlock>();
public DbSet<HazardClass> HazardClasses => Set<HazardClass>();
public DbSet<HazardClassBlock> HazardClassBlocks => Set<HazardClassBlock>();
public DbSet<Species> Species => Set<Species>();
public DbSet<SpeciesBlock> SpeciesBlocks => Set<SpeciesBlock>();
public DbSet<Allergen> Allergens => Set<Allergen>();
public DbSet<AllergenBlock> AllergenBlocks => Set<AllergenBlock>();
protected override void OnModelCreating(ModelBuilder model) protected override void OnModelCreating(ModelBuilder model)
{ {
// JSON converters/comparers
var jsonOpts = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var dictStrConverter = new ValueConverter<Dictionary<string, string>?, string?>(
v => v == null ? null : JsonSerializer.Serialize(v, jsonOpts),
v => string.IsNullOrWhiteSpace(v)
? null
: JsonSerializer.Deserialize<Dictionary<string, string>>(v!, jsonOpts)!);
var dictStrComparer = new ValueComparer<Dictionary<string, string>?>(
(l, r) => JsonSerializer.Serialize(l, jsonOpts) == JsonSerializer.Serialize(r, jsonOpts),
v => v == null ? 0 : JsonSerializer.Serialize(v, jsonOpts).GetHashCode(),
v => v == null
? null
: JsonSerializer.Deserialize<Dictionary<string, string>>(JsonSerializer.Serialize(v, jsonOpts),
jsonOpts));
var dictObjConverter = new ValueConverter<Dictionary<string, object>?, string?>(
v => v == null ? null : JsonSerializer.Serialize(v, jsonOpts),
v => string.IsNullOrWhiteSpace(v)
? null
: JsonSerializer.Deserialize<Dictionary<string, object>>(v!, jsonOpts)!);
var dictObjComparer = new ValueComparer<Dictionary<string, object>?>(
(l, r) => JsonSerializer.Serialize(l, jsonOpts) == JsonSerializer.Serialize(r, jsonOpts),
v => v == null ? 0 : JsonSerializer.Serialize(v, jsonOpts).GetHashCode(),
v => v == null
? null
: JsonSerializer.Deserialize<Dictionary<string, object>>(JsonSerializer.Serialize(v, jsonOpts),
jsonOpts));
void ConfigureMasterBase<TEntity>(string table, string schema = "master")
where TEntity : MasterBase
{
var b = model.Entity<TEntity>();
b.ToTable(table, schema);
b.HasKey(x => x.Id);
b.HasAlternateKey(x => new { x.TenantId, x.Id });
b.Property(x => x.Scope).IsRequired().HasDefaultValue("global");
b.Property(x => x.Code).IsRequired();
b.Property(x => x.Name).IsRequired();
b.Property(x => x.IsActive).HasDefaultValue(true);
b.Property(x => x.IsSystem).HasDefaultValue(false);
var nameI18nProp = b.Property(x => x.NameI18n).HasConversion(dictStrConverter);
nameI18nProp.Metadata.SetValueComparer(dictStrComparer);
nameI18nProp.HasColumnType("jsonb");
var metaProp = b.Property(x => x.Meta).HasConversion(dictObjConverter);
metaProp.Metadata.SetValueComparer(dictObjComparer);
metaProp.HasColumnType("jsonb");
b.HasIndex(x => new { x.Scope, x.TenantId, x.Code }).IsUnique();
// b.HasIndex(x => new { x.TenantId, x.OverridesGlobalId }).IsUnique().HasFilter("\"OverridesGlobalId\" IS NOT NULL");
}
void ConfigureBlockBase<TBlock, TMaster>(string table, string schema = "master")
where TBlock : MasterBlockBase
where TMaster : MasterBase
{
var b = model.Entity<TBlock>();
b.ToTable(table, schema);
b.HasKey(x => x.Id);
b.HasIndex(x => new { x.TenantId, x.GlobalId }).IsUnique();
b.HasIndex(x => x.GlobalId);
b.HasOne<TMaster>()
.WithMany()
.HasForeignKey(x => x.GlobalId)
.OnDelete(DeleteBehavior.Cascade);
}
// ====== Tenancy (meta) ====== // ====== Tenancy (meta) ======
model.Entity<TenantConfig>(b => model.Entity<TenantConfig>(b =>
{ {
b.ToTable("tenants", schema: "meta"); b.ToTable("tenants", schema: "meta");
b.HasKey(x => x.TenantKey); // PK = key (slug) b.HasKey(x => x.TenantKey);
b.HasAlternateKey(x => x.TenantId); // AK = GUID b.HasAlternateKey(x => x.TenantId);
b.HasIndex(x => x.TenantId).IsUnique(); b.HasIndex(x => x.TenantId).IsUnique();
b.Property(x => x.TenantKey).HasMaxLength(128).IsRequired(); b.Property(x => x.TenantKey).HasMaxLength(128).IsRequired();
@@ -51,8 +214,7 @@ public class AppDbContext : DbContext
b.Property(x => x.ConnectionString); b.Property(x => x.ConnectionString);
b.Property(x => x.Mode).IsRequired(); b.Property(x => x.Mode).IsRequired();
b.Property(x => x.IsActive).HasDefaultValue(true); b.Property(x => x.IsActive).HasDefaultValue(true);
b.Property(x => x.UpdatedAtUtc) b.Property(x => x.UpdatedAtUtc).HasColumnName("updated_at_utc")
.HasColumnName("updated_at_utc")
.HasDefaultValueSql("now() at time zone 'utc'"); .HasDefaultValueSql("now() at time zone 'utc'");
b.HasIndex(x => x.IsActive); b.HasIndex(x => x.IsActive);
}); });
@@ -62,11 +224,10 @@ public class AppDbContext : DbContext
b.ToTable("tenant_domains", schema: "meta"); b.ToTable("tenant_domains", schema: "meta");
b.HasKey(x => x.Domain); b.HasKey(x => x.Domain);
b.Property(x => x.Domain).HasMaxLength(253).IsRequired(); b.Property(x => x.Domain).HasMaxLength(253).IsRequired();
b.Property(x => x.TenantKey).HasMaxLength(128); // optional b.Property(x => x.TenantKey).HasMaxLength(128);
b.Property(x => x.IsPlatformBaseDomain).HasDefaultValue(false); b.Property(x => x.IsPlatformBaseDomain).HasDefaultValue(false);
b.Property(x => x.IsActive).HasDefaultValue(true); b.Property(x => x.IsActive).HasDefaultValue(true);
b.Property(x => x.UpdatedAtUtc) b.Property(x => x.UpdatedAtUtc).HasColumnName("updated_at_utc")
.HasColumnName("updated_at_utc")
.HasDefaultValueSql("now() at time zone 'utc'"); .HasDefaultValueSql("now() at time zone 'utc'");
b.HasIndex(x => x.TenantKey); b.HasIndex(x => x.TenantKey);
@@ -84,8 +245,6 @@ public class AppDbContext : DbContext
{ {
b.ToTable("users"); b.ToTable("users");
b.HasKey(x => x.Id); b.HasKey(x => x.Id);
// principal key สำหรับ composite FK จากลูก ๆ
b.HasAlternateKey(u => new { u.TenantId, u.Id }); b.HasAlternateKey(u => new { u.TenantId, u.Id });
b.Property(x => x.PasswordHash).IsRequired(); b.Property(x => x.PasswordHash).IsRequired();
@@ -105,14 +264,13 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.Type, x.Identifier }).IsUnique(); b.HasIndex(x => new { x.TenantId, x.Type, x.Identifier }).IsUnique();
b.HasIndex(x => new { x.TenantId, x.UserId, x.Type, x.IsPrimary }) b.HasIndex(x => new { x.TenantId, x.UserId, x.Type, x.IsPrimary })
.HasDatabaseName("ix_user_identity_primary_per_type"); .HasDatabaseName("ix_user_identity_primary_per_type");
// (TenantId, UserId) -> User.(TenantId, Id)
b.HasOne(i => i.User) b.HasOne(i => i.User)
.WithMany(u => u.Identities) .WithMany(u => u.Identities)
.HasForeignKey(i => new { i.TenantId, i.UserId }) .HasForeignKey(i => new { i.TenantId, i.UserId })
.HasPrincipalKey(nameof(User.TenantId), nameof(User.Id)) .HasPrincipalKey(nameof(User.TenantId), nameof(User.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
model.Entity<UserMfaFactor>(b => model.Entity<UserMfaFactor>(b =>
@@ -125,10 +283,10 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.UserId }); b.HasIndex(x => new { x.TenantId, x.UserId });
b.HasOne(x => x.User) b.HasOne(x => x.User)
.WithMany(u => u.MfaFactors) .WithMany(u => u.MfaFactors)
.HasForeignKey(x => new { x.TenantId, x.UserId }) .HasForeignKey(x => new { x.TenantId, x.UserId })
.HasPrincipalKey(nameof(User.TenantId), nameof(User.Id)) .HasPrincipalKey(nameof(User.TenantId), nameof(User.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
model.Entity<UserSession>(b => model.Entity<UserSession>(b =>
@@ -141,10 +299,10 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.DeviceId }); b.HasIndex(x => new { x.TenantId, x.DeviceId });
b.HasOne(x => x.User) b.HasOne(x => x.User)
.WithMany(u => u.Sessions) .WithMany(u => u.Sessions)
.HasForeignKey(x => new { x.TenantId, x.UserId }) .HasForeignKey(x => new { x.TenantId, x.UserId })
.HasPrincipalKey(nameof(User.TenantId), nameof(User.Id)) .HasPrincipalKey(nameof(User.TenantId), nameof(User.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
model.Entity<UserPasswordHistory>(b => model.Entity<UserPasswordHistory>(b =>
@@ -155,10 +313,10 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.UserId, x.ChangedAt }); b.HasIndex(x => new { x.TenantId, x.UserId, x.ChangedAt });
b.HasOne(x => x.User) b.HasOne(x => x.User)
.WithMany(u => u.PasswordHistories) .WithMany(u => u.PasswordHistories)
.HasForeignKey(x => new { x.TenantId, x.UserId }) .HasForeignKey(x => new { x.TenantId, x.UserId })
.HasPrincipalKey(nameof(User.TenantId), nameof(User.Id)) .HasPrincipalKey(nameof(User.TenantId), nameof(User.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
model.Entity<UserExternalAccount>(b => model.Entity<UserExternalAccount>(b =>
@@ -171,10 +329,10 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.Provider, x.Subject }).IsUnique(); b.HasIndex(x => new { x.TenantId, x.Provider, x.Subject }).IsUnique();
b.HasOne(x => x.User) b.HasOne(x => x.User)
.WithMany(u => u.ExternalAccounts) .WithMany(u => u.ExternalAccounts)
.HasForeignKey(x => new { x.TenantId, x.UserId }) .HasForeignKey(x => new { x.TenantId, x.UserId })
.HasPrincipalKey(nameof(User.TenantId), nameof(User.Id)) .HasPrincipalKey(nameof(User.TenantId), nameof(User.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
model.Entity<Role>(b => model.Entity<Role>(b =>
@@ -209,16 +367,16 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique(); b.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique();
b.HasOne<User>() b.HasOne<User>()
.WithMany() .WithMany()
.HasForeignKey(x => new { x.TenantId, x.UserId }) .HasForeignKey(x => new { x.TenantId, x.UserId })
.HasPrincipalKey(nameof(User.TenantId), nameof(User.Id)) .HasPrincipalKey(nameof(User.TenantId), nameof(User.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne<Role>() b.HasOne<Role>()
.WithMany() .WithMany()
.HasForeignKey(x => new { x.TenantId, x.RoleId }) .HasForeignKey(x => new { x.TenantId, x.RoleId })
.HasPrincipalKey(nameof(Role.TenantId), nameof(Role.Id)) .HasPrincipalKey(nameof(Role.TenantId), nameof(Role.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
model.Entity<RolePermission>(b => model.Entity<RolePermission>(b =>
@@ -229,16 +387,16 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique(); b.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique();
b.HasOne<Role>() b.HasOne<Role>()
.WithMany() .WithMany()
.HasForeignKey(x => new { x.TenantId, x.RoleId }) .HasForeignKey(x => new { x.TenantId, x.RoleId })
.HasPrincipalKey(nameof(Role.TenantId), nameof(Role.Id)) .HasPrincipalKey(nameof(Role.TenantId), nameof(Role.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne<Permission>() b.HasOne<Permission>()
.WithMany() .WithMany()
.HasForeignKey(x => new { x.TenantId, x.PermissionId }) .HasForeignKey(x => new { x.TenantId, x.PermissionId })
.HasPrincipalKey(nameof(Permission.TenantId), nameof(Permission.Id)) .HasPrincipalKey(nameof(Permission.TenantId), nameof(Permission.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
// ====== HR ====== // ====== HR ======
@@ -256,7 +414,7 @@ public class AppDbContext : DbContext
b.HasOne(x => x.User) b.HasOne(x => x.User)
.WithOne() .WithOne()
.HasForeignKey<UserProfile>(x => new { x.TenantId, x.UserId }) .HasForeignKey<UserProfile>(x => new { x.TenantId, x.UserId })
.HasPrincipalKey<User>(u => new { u.TenantId, u.Id }) // <-- เปลี่ยนตรงนี้ .HasPrincipalKey<User>(u => new { u.TenantId, u.Id })
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
@@ -272,10 +430,10 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); b.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
b.HasOne(x => x.Parent) b.HasOne(x => x.Parent)
.WithMany(x => x.Children) .WithMany(x => x.Children)
.HasForeignKey(x => new { x.TenantId, x.ParentDepartmentId }) .HasForeignKey(x => new { x.TenantId, x.ParentDepartmentId })
.HasPrincipalKey(nameof(Department.TenantId), nameof(Department.Id)) .HasPrincipalKey(nameof(Department.TenantId), nameof(Department.Id))
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
model.Entity<Position>(b => model.Entity<Position>(b =>
@@ -301,22 +459,22 @@ public class AppDbContext : DbContext
b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.StartDate }); b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.StartDate });
b.HasOne(x => x.UserProfile) b.HasOne(x => x.UserProfile)
.WithMany(p => p.Employments) .WithMany(p => p.Employments)
.HasForeignKey(x => new { x.TenantId, x.UserProfileId }) .HasForeignKey(x => new { x.TenantId, x.UserProfileId })
.HasPrincipalKey(nameof(UserProfile.TenantId), nameof(UserProfile.Id)) .HasPrincipalKey(nameof(UserProfile.TenantId), nameof(UserProfile.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Department) b.HasOne(x => x.Department)
.WithMany() .WithMany()
.HasForeignKey(x => new { x.TenantId, x.DepartmentId }) .HasForeignKey(x => new { x.TenantId, x.DepartmentId })
.HasPrincipalKey(nameof(Department.TenantId), nameof(Department.Id)) .HasPrincipalKey(nameof(Department.TenantId), nameof(Department.Id))
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
b.HasOne(x => x.Position) b.HasOne(x => x.Position)
.WithMany() .WithMany()
.HasForeignKey(x => new { x.TenantId, x.PositionId }) .HasForeignKey(x => new { x.TenantId, x.PositionId })
.HasPrincipalKey(nameof(Position.TenantId), nameof(Position.Id)) .HasPrincipalKey(nameof(Position.TenantId), nameof(Position.Id))
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
model.Entity<EmployeeAddress>(b => model.Entity<EmployeeAddress>(b =>
@@ -330,10 +488,10 @@ public class AppDbContext : DbContext
b.Property(x => x.Country).IsRequired().HasMaxLength(64); b.Property(x => x.Country).IsRequired().HasMaxLength(64);
b.HasOne(x => x.UserProfile) b.HasOne(x => x.UserProfile)
.WithMany(p => p.Addresses) .WithMany(p => p.Addresses)
.HasForeignKey(x => new { x.TenantId, x.UserProfileId }) .HasForeignKey(x => new { x.TenantId, x.UserProfileId })
.HasPrincipalKey(nameof(UserProfile.TenantId), nameof(UserProfile.Id)) .HasPrincipalKey(nameof(UserProfile.TenantId), nameof(UserProfile.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary }); b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
}); });
@@ -347,10 +505,10 @@ public class AppDbContext : DbContext
b.Property(x => x.Relationship).IsRequired().HasMaxLength(64); b.Property(x => x.Relationship).IsRequired().HasMaxLength(64);
b.HasOne(x => x.UserProfile) b.HasOne(x => x.UserProfile)
.WithMany(p => p.EmergencyContacts) .WithMany(p => p.EmergencyContacts)
.HasForeignKey(x => new { x.TenantId, x.UserProfileId }) .HasForeignKey(x => new { x.TenantId, x.UserProfileId })
.HasPrincipalKey(nameof(UserProfile.TenantId), nameof(UserProfile.Id)) .HasPrincipalKey(nameof(UserProfile.TenantId), nameof(UserProfile.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary }); b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
}); });
@@ -365,28 +523,280 @@ public class AppDbContext : DbContext
b.Property(x => x.AccountHolder).IsRequired().HasMaxLength(128); b.Property(x => x.AccountHolder).IsRequired().HasMaxLength(128);
b.HasOne(x => x.UserProfile) b.HasOne(x => x.UserProfile)
.WithMany(p => p.BankAccounts) .WithMany(p => p.BankAccounts)
.HasForeignKey(x => new { x.TenantId, x.UserProfileId }) .HasForeignKey(x => new { x.TenantId, x.UserProfileId })
.HasPrincipalKey(nameof(UserProfile.TenantId), nameof(UserProfile.Id)) .HasPrincipalKey(nameof(UserProfile.TenantId), nameof(UserProfile.Id))
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary }); b.HasIndex(x => new { x.TenantId, x.UserProfileId, x.IsPrimary });
}); });
// ====== Enums as ints ====== // ====== Customers ======
model.Entity<CustomerProfile>(b =>
{
b.ToTable("customer_profiles");
b.HasKey(x => x.Id);
b.HasAlternateKey(x => new { x.TenantId, x.Id });
b.Property(x => x.DisplayName).HasMaxLength(256).IsRequired();
b.Property(x => x.Type).IsRequired();
b.Property(x => x.Status).IsRequired();
b.HasIndex(x => new { x.TenantId, x.DisplayName });
b.HasMany(x => x.Contacts)
.WithOne(x => x.Customer)
.HasForeignKey(x => new { x.TenantId, x.CustomerProfileId })
.HasPrincipalKey(x => new { x.TenantId, x.Id })
.OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Addresses)
.WithOne(x => x.Customer)
.HasForeignKey(x => new { x.TenantId, x.CustomerProfileId })
.HasPrincipalKey(x => new { x.TenantId, x.Id })
.OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Tags)
.WithOne(x => x.Customer)
.HasForeignKey(x => new { x.TenantId, x.CustomerProfileId })
.HasPrincipalKey(x => new { x.TenantId, x.Id })
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Person)
.WithOne(x => x.Customer)
.HasForeignKey<PersonProfile>(x => new { x.TenantId, x.CustomerProfileId })
.HasPrincipalKey<CustomerProfile>(x => new { x.TenantId, x.Id })
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Organization)
.WithOne(x => x.Customer)
.HasForeignKey<OrganizationProfile>(x => new { x.TenantId, x.CustomerProfileId })
.HasPrincipalKey<CustomerProfile>(x => new { x.TenantId, x.Id })
.OnDelete(DeleteBehavior.Cascade);
});
model.Entity<PersonProfile>(b =>
{
b.ToTable("person_profiles");
b.HasKey(x => x.Id);
b.Property(x => x.Prefix).HasMaxLength(32);
b.Property(x => x.FirstNameTh).HasMaxLength(128);
b.Property(x => x.LastNameTh).HasMaxLength(128);
b.Property(x => x.FirstNameEn).HasMaxLength(128);
b.Property(x => x.LastNameEn).HasMaxLength(128);
b.Property(x => x.NationalId).HasMaxLength(13);
b.Property(x => x.PassportNo).HasMaxLength(32);
b.HasIndex(x => new { x.TenantId, x.CustomerProfileId }).IsUnique();
});
model.Entity<OrganizationProfile>(b =>
{
b.ToTable("organization_profiles");
b.HasKey(x => x.Id);
b.Property(x => x.LegalNameTh).HasMaxLength(256).IsRequired();
b.Property(x => x.LegalNameEn).HasMaxLength(256);
b.Property(x => x.RegistrationNo).HasMaxLength(64);
b.Property(x => x.TaxId13).HasMaxLength(13);
b.Property(x => x.BranchCode).HasMaxLength(5);
b.Property(x => x.CompanyType).HasMaxLength(64);
b.HasIndex(x => new { x.TenantId, x.CustomerProfileId }).IsUnique();
b.HasIndex(x => new { x.TenantId, x.TaxId13, x.BranchCode }).IsUnique()
.HasFilter("\"TaxId13\" IS NOT NULL");
});
model.Entity<CustomerContact>(b =>
{
b.ToTable("customer_contacts");
b.HasKey(x => x.Id);
b.Property(x => x.Type).IsRequired();
b.Property(x => x.Value).HasMaxLength(256).IsRequired();
b.Property(x => x.IsPrimary).HasDefaultValue(false);
b.Property(x => x.IsVerified).HasDefaultValue(false);
b.HasIndex(x => new { x.TenantId, x.CustomerProfileId, x.Type });
b.HasIndex(x => new { x.TenantId, x.CustomerProfileId, x.Type, x.IsPrimary })
.HasDatabaseName("ux_customer_contact_primary_per_type")
.IsUnique()
.HasFilter("\"IsPrimary\" = TRUE");
});
model.Entity<Address>(b =>
{
b.ToTable("addresses");
b.HasKey(x => x.Id);
b.HasAlternateKey(x => new { x.TenantId, x.Id });
b.Property(x => x.Line1).HasMaxLength(256);
b.Property(x => x.Line2).HasMaxLength(256);
b.Property(x => x.Village).HasMaxLength(128);
b.Property(x => x.Soi).HasMaxLength(128);
b.Property(x => x.Road).HasMaxLength(128);
b.Property(x => x.Subdistrict).HasMaxLength(128);
b.Property(x => x.District).HasMaxLength(128);
b.Property(x => x.Province).HasMaxLength(128);
b.Property(x => x.PostalCode).HasMaxLength(10);
b.Property(x => x.CountryCode).HasMaxLength(2).IsRequired();
});
model.Entity<CustomerAddress>(b =>
{
b.ToTable("customer_addresses");
b.HasKey(x => x.Id);
b.Property(x => x.Label).IsRequired();
b.Property(x => x.IsDefault).HasDefaultValue(false);
b.HasIndex(x => new { x.TenantId, x.CustomerProfileId, x.Label });
b.HasOne(x => x.Customer)
.WithMany(c => c.Addresses)
.HasForeignKey(x => new { x.TenantId, x.CustomerProfileId })
.HasPrincipalKey(c => new { c.TenantId, c.Id })
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Address)
.WithMany(a => a.CustomerLinks)
.HasForeignKey(x => new { x.TenantId, x.AddressId })
.HasPrincipalKey(a => new { a.TenantId, a.Id })
.OnDelete(DeleteBehavior.Cascade);
b.HasIndex(x => new { x.TenantId, x.CustomerProfileId, x.Label, x.IsDefault })
.HasDatabaseName("ux_default_customer_address_per_label")
.IsUnique()
.HasFilter("\"IsDefault\" = TRUE");
});
model.Entity<CustomerTag>(b =>
{
b.ToTable("customer_tags");
b.HasKey(x => x.Id);
b.Property(x => x.Tag).HasMaxLength(64).IsRequired();
b.HasIndex(x => new { x.TenantId, x.CustomerProfileId, x.Tag }).IsUnique();
});
// ===== Master: mapping =====
ConfigureMasterBase<Brand>("brands");
ConfigureBlockBase<BrandBlock, Brand>("brand_blocks");
model.Entity<Category>(b =>
{
ConfigureMasterBase<Category>("categories");
b.HasOne(x => x.Parent)
.WithMany(x => x.Children)
.HasForeignKey(x => new { x.TenantId, x.ParentId })
.HasPrincipalKey(x => new { x.TenantId, x.Id })
.OnDelete(DeleteBehavior.Restrict);
b.HasIndex(x => x.ParentId);
b.HasIndex(x => x.Path);
b.HasIndex(x => new { x.Scope, x.TenantId, x.SortOrder });
});
ConfigureBlockBase<CategoryBlock, Category>("category_blocks");
model.Entity<CategoryExt>(b =>
{
b.ToTable("category_exts", "master");
b.HasKey(x => x.CategoryId);
b.HasOne(x => x.Category).WithOne().HasForeignKey<CategoryExt>(x => x.CategoryId)
.OnDelete(DeleteBehavior.Cascade);
var chVisProp = b.Property(x => x.ChannelVisibility).HasConversion(dictObjConverter);
chVisProp.Metadata.SetValueComparer(dictObjComparer);
chVisProp.HasColumnType("jsonb");
var facetSchemaProp = b.Property(x => x.FacetSchema).HasConversion(dictObjConverter);
facetSchemaProp.Metadata.SetValueComparer(dictObjComparer);
facetSchemaProp.HasColumnType("jsonb");
});
ConfigureMasterBase<Uom>("uoms");
ConfigureBlockBase<UomBlock, Uom>("uom_blocks");
ConfigureMasterBase<Currency>("currencies");
ConfigureBlockBase<CurrencyBlock, Currency>("currency_blocks");
ConfigureMasterBase<Country>("countries");
ConfigureBlockBase<CountryBlock, Country>("country_blocks");
ConfigureMasterBase<Manufacturer>("manufacturers");
ConfigureBlockBase<ManufacturerBlock, Manufacturer>("manufacturer_blocks");
ConfigureMasterBase<Market>("markets");
ConfigureBlockBase<MarketBlock, Market>("market_blocks");
ConfigureMasterBase<Language>("languages");
ConfigureBlockBase<LanguageBlock, Language>("language_blocks");
ConfigureMasterBase<ComplianceStatus>("compliance_statuses");
ConfigureBlockBase<ComplianceStatusBlock, ComplianceStatus>("compliance_status_blocks");
ConfigureMasterBase<QaStage>("qa_stages");
ConfigureBlockBase<QaStageBlock, QaStage>("qa_stage_blocks");
ConfigureMasterBase<Route>("routes");
ConfigureBlockBase<RouteBlock, Route>("route_blocks");
ConfigureMasterBase<RxSchedule>("rx_schedules");
ConfigureBlockBase<RxScheduleBlock, RxSchedule>("rx_schedule_blocks");
ConfigureMasterBase<StabilityStatus>("stability_statuses");
ConfigureBlockBase<StabilityStatusBlock, StabilityStatus>("stability_status_blocks");
ConfigureMasterBase<RiskClass>("risk_classes");
ConfigureBlockBase<RiskClassBlock, RiskClass>("risk_class_blocks");
ConfigureMasterBase<SterilizationMethod>("sterilization_methods");
ConfigureBlockBase<SterilizationMethodBlock, SterilizationMethod>("sterilization_method_blocks");
ConfigureMasterBase<QcStatus>("qc_statuses");
ConfigureBlockBase<QcStatusBlock, QcStatus>("qc_status_blocks");
ConfigureMasterBase<DocControlStatus>("doc_control_statuses");
ConfigureBlockBase<DocControlStatusBlock, DocControlStatus>("doc_control_status_blocks");
ConfigureMasterBase<RecallClass>("recall_classes");
ConfigureBlockBase<RecallClassBlock, RecallClass>("recall_class_blocks");
ConfigureMasterBase<PackingGroup>("packing_groups");
ConfigureBlockBase<PackingGroupBlock, PackingGroup>("packing_group_blocks");
ConfigureMasterBase<Vvm>("vvms");
ConfigureBlockBase<VvmBlock, Vvm>("vvm_blocks");
ConfigureMasterBase<FuncTest>("func_tests");
ConfigureBlockBase<FuncTestBlock, FuncTest>("func_test_blocks");
ConfigureMasterBase<HazardClass>("hazard_classes");
ConfigureBlockBase<HazardClassBlock, HazardClass>("hazard_class_blocks");
ConfigureMasterBase<Species>("species");
ConfigureBlockBase<SpeciesBlock, Species>("species_blocks");
ConfigureMasterBase<Allergen>("allergens");
ConfigureBlockBase<AllergenBlock, Allergen>("allergen_blocks");
// ====== Enum conversions ======
model.Entity<UserIdentity>().Property(x => x.Type).HasConversion<int>(); model.Entity<UserIdentity>().Property(x => x.Type).HasConversion<int>();
model.Entity<UserMfaFactor>().Property(x => x.Type).HasConversion<int>(); model.Entity<UserMfaFactor>().Property(x => x.Type).HasConversion<int>();
model.Entity<UserExternalAccount>().Property(x => x.Provider).HasConversion<int>(); model.Entity<UserExternalAccount>().Property(x => x.Provider).HasConversion<int>();
model.Entity<Employment>().Property(x => x.EmploymentType).HasConversion<int>(); model.Entity<Employment>().Property(x => x.EmploymentType).HasConversion<int>();
model.Entity<UserProfile>().Property(x => x.Gender).HasConversion<int?>(); model.Entity<UserProfile>().Property(x => x.Gender).HasConversion<int?>();
model.Entity<CustomerProfile>().Property(x => x.Type).HasConversion<int>();
model.Entity<CustomerProfile>().Property(x => x.Status).HasConversion<int>();
model.Entity<CustomerContact>().Property(x => x.Type).HasConversion<int>();
model.Entity<CustomerAddress>().Property(x => x.Label).HasConversion<int>();
// ====== BaseEntity common mapping ====== // ====== BaseEntity common mapping ======
foreach (var et in model.Model.GetEntityTypes() foreach (var et in model.Model.GetEntityTypes().Where(t => typeof(BaseEntity).IsAssignableFrom(t.ClrType)))
.Where(t => typeof(BaseEntity).IsAssignableFrom(t.ClrType)))
{ {
var b = model.Entity(et.ClrType); var b = model.Entity(et.ClrType);
// Tenant
b.Property<Guid>(nameof(BaseEntity.TenantId)) b.Property<Guid>(nameof(BaseEntity.TenantId))
.HasColumnName("tenant_id") .HasColumnName("tenant_id")
.HasColumnType("uuid") .HasColumnType("uuid")
@@ -395,23 +805,19 @@ public class AppDbContext : DbContext
b.HasIndex(nameof(BaseEntity.TenantId)); b.HasIndex(nameof(BaseEntity.TenantId));
// ชื่อ constraint สร้างแบบ concat แทน string interpolation กัน ambiguous handler
var tn = et.GetTableName(); var tn = et.GetTableName();
if (!string.IsNullOrEmpty(tn)) if (!string.IsNullOrEmpty(tn))
{ {
b.HasCheckConstraint(string.Concat("ck_", tn, "_tenant_not_null"), "tenant_id is not null"); b.HasCheckConstraint($"ck_{tn}_tenant_not_null", "tenant_id is not null");
b.HasCheckConstraint(string.Concat("ck_", tn, "_tenant_not_zero"), b.HasCheckConstraint($"ck_{tn}_tenant_not_zero", "tenant_id <> '00000000-0000-0000-0000-000000000000'");
"tenant_id <> '00000000-0000-0000-0000-000000000000'");
} }
// Audit b.Property<DateTimeOffset>(nameof(BaseEntity.CreatedAt)).HasColumnName("created_at")
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnName("created_at")
.HasDefaultValueSql("now() at time zone 'utc'"); .HasDefaultValueSql("now() at time zone 'utc'");
b.Property<DateTimeOffset?>("UpdatedAt").HasColumnName("updated_at"); b.Property<DateTimeOffset?>(nameof(BaseEntity.UpdatedAt)).HasColumnName("updated_at");
b.Property<string?>("CreatedBy").HasColumnName("created_by"); b.Property<string?>(nameof(BaseEntity.CreatedBy)).HasColumnName("created_by");
b.Property<string?>("UpdatedBy").HasColumnName("updated_by"); b.Property<string?>(nameof(BaseEntity.UpdatedBy)).HasColumnName("updated_by");
b.Property<bool>("IsDeleted").HasColumnName("is_deleted").HasDefaultValue(false); b.Property<bool>(nameof(BaseEntity.IsDeleted)).HasColumnName("is_deleted").HasDefaultValue(false);
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,348 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AMREZ.EOP.Infrastructures.Migrations
{
/// <inheritdoc />
public partial class AddCustomer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "addresses",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Line1 = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Line2 = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Village = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
Soi = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
Road = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
Subdistrict = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
District = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
Province = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
PostalCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
CountryCode = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: false),
Verified = table.Column<bool>(type: "boolean", nullable: false),
GeoLat = table.Column<double>(type: "double precision", nullable: true),
GeoLng = table.Column<double>(type: "double precision", nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_addresses", x => x.Id);
table.UniqueConstraint("AK_addresses_tenant_id_Id", x => new { x.tenant_id, x.Id });
table.CheckConstraint("ck_addresses_tenant_not_null", "tenant_id is not null");
table.CheckConstraint("ck_addresses_tenant_not_zero", "tenant_id <> '00000000-0000-0000-0000-000000000000'");
});
migrationBuilder.CreateTable(
name: "customer_profiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
DisplayName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_customer_profiles", x => x.Id);
table.UniqueConstraint("AK_customer_profiles_tenant_id_Id", x => new { x.tenant_id, x.Id });
table.CheckConstraint("ck_customer_profiles_tenant_not_null", "tenant_id is not null");
table.CheckConstraint("ck_customer_profiles_tenant_not_zero", "tenant_id <> '00000000-0000-0000-0000-000000000000'");
});
migrationBuilder.CreateTable(
name: "customer_addresses",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CustomerProfileId = table.Column<Guid>(type: "uuid", nullable: false),
AddressId = table.Column<Guid>(type: "uuid", nullable: false),
Label = table.Column<int>(type: "integer", nullable: false),
IsDefault = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_customer_addresses", x => x.Id);
table.CheckConstraint("ck_customer_addresses_tenant_not_null", "tenant_id is not null");
table.CheckConstraint("ck_customer_addresses_tenant_not_zero", "tenant_id <> '00000000-0000-0000-0000-000000000000'");
table.ForeignKey(
name: "FK_customer_addresses_addresses_tenant_id_AddressId",
columns: x => new { x.tenant_id, x.AddressId },
principalTable: "addresses",
principalColumns: new[] { "tenant_id", "Id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_customer_addresses_customer_profiles_tenant_id_CustomerProf~",
columns: x => new { x.tenant_id, x.CustomerProfileId },
principalTable: "customer_profiles",
principalColumns: new[] { "tenant_id", "Id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "customer_contacts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CustomerProfileId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Value = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
IsVerified = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_customer_contacts", x => x.Id);
table.CheckConstraint("ck_customer_contacts_tenant_not_null", "tenant_id is not null");
table.CheckConstraint("ck_customer_contacts_tenant_not_zero", "tenant_id <> '00000000-0000-0000-0000-000000000000'");
table.ForeignKey(
name: "FK_customer_contacts_customer_profiles_tenant_id_CustomerProfi~",
columns: x => new { x.tenant_id, x.CustomerProfileId },
principalTable: "customer_profiles",
principalColumns: new[] { "tenant_id", "Id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "customer_tags",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CustomerProfileId = table.Column<Guid>(type: "uuid", nullable: false),
Tag = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_customer_tags", x => x.Id);
table.CheckConstraint("ck_customer_tags_tenant_not_null", "tenant_id is not null");
table.CheckConstraint("ck_customer_tags_tenant_not_zero", "tenant_id <> '00000000-0000-0000-0000-000000000000'");
table.ForeignKey(
name: "FK_customer_tags_customer_profiles_tenant_id_CustomerProfileId",
columns: x => new { x.tenant_id, x.CustomerProfileId },
principalTable: "customer_profiles",
principalColumns: new[] { "tenant_id", "Id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "organization_profiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CustomerProfileId = table.Column<Guid>(type: "uuid", nullable: false),
LegalNameTh = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
LegalNameEn = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
RegistrationNo = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
TaxId13 = table.Column<string>(type: "character varying(13)", maxLength: 13, nullable: true),
BranchCode = table.Column<string>(type: "character varying(5)", maxLength: 5, nullable: true),
CompanyType = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_organization_profiles", x => x.Id);
table.CheckConstraint("ck_organization_profiles_tenant_not_null", "tenant_id is not null");
table.CheckConstraint("ck_organization_profiles_tenant_not_zero", "tenant_id <> '00000000-0000-0000-0000-000000000000'");
table.ForeignKey(
name: "FK_organization_profiles_customer_profiles_tenant_id_CustomerP~",
columns: x => new { x.tenant_id, x.CustomerProfileId },
principalTable: "customer_profiles",
principalColumns: new[] { "tenant_id", "Id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "person_profiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CustomerProfileId = table.Column<Guid>(type: "uuid", nullable: false),
Prefix = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
FirstNameTh = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
LastNameTh = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
FirstNameEn = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
LastNameEn = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
BirthDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
NationalId = table.Column<string>(type: "character varying(13)", maxLength: 13, nullable: true),
PassportNo = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() at time zone 'utc'"),
created_by = table.Column<string>(type: "text", nullable: true),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
updated_by = table.Column<string>(type: "text", nullable: true),
is_deleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_person_profiles", x => x.Id);
table.CheckConstraint("ck_person_profiles_tenant_not_null", "tenant_id is not null");
table.CheckConstraint("ck_person_profiles_tenant_not_zero", "tenant_id <> '00000000-0000-0000-0000-000000000000'");
table.ForeignKey(
name: "FK_person_profiles_customer_profiles_tenant_id_CustomerProfile~",
columns: x => new { x.tenant_id, x.CustomerProfileId },
principalTable: "customer_profiles",
principalColumns: new[] { "tenant_id", "Id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_addresses_tenant_id",
table: "addresses",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_customer_addresses_tenant_id",
table: "customer_addresses",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_customer_addresses_tenant_id_AddressId",
table: "customer_addresses",
columns: new[] { "tenant_id", "AddressId" });
migrationBuilder.CreateIndex(
name: "IX_customer_addresses_tenant_id_CustomerProfileId_Label",
table: "customer_addresses",
columns: new[] { "tenant_id", "CustomerProfileId", "Label" });
migrationBuilder.CreateIndex(
name: "ux_default_customer_address_per_label",
table: "customer_addresses",
columns: new[] { "tenant_id", "CustomerProfileId", "Label", "IsDefault" },
unique: true,
filter: "\"IsDefault\" = TRUE");
migrationBuilder.CreateIndex(
name: "IX_customer_contacts_tenant_id",
table: "customer_contacts",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_customer_contacts_tenant_id_CustomerProfileId_Type",
table: "customer_contacts",
columns: new[] { "tenant_id", "CustomerProfileId", "Type" });
migrationBuilder.CreateIndex(
name: "ux_customer_contact_primary_per_type",
table: "customer_contacts",
columns: new[] { "tenant_id", "CustomerProfileId", "Type", "IsPrimary" },
unique: true,
filter: "\"IsPrimary\" = TRUE");
migrationBuilder.CreateIndex(
name: "IX_customer_profiles_tenant_id",
table: "customer_profiles",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_customer_profiles_tenant_id_DisplayName",
table: "customer_profiles",
columns: new[] { "tenant_id", "DisplayName" });
migrationBuilder.CreateIndex(
name: "IX_customer_tags_tenant_id",
table: "customer_tags",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_customer_tags_tenant_id_CustomerProfileId_Tag",
table: "customer_tags",
columns: new[] { "tenant_id", "CustomerProfileId", "Tag" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_organization_profiles_tenant_id",
table: "organization_profiles",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_organization_profiles_tenant_id_CustomerProfileId",
table: "organization_profiles",
columns: new[] { "tenant_id", "CustomerProfileId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_organization_profiles_tenant_id_TaxId13_BranchCode",
table: "organization_profiles",
columns: new[] { "tenant_id", "TaxId13", "BranchCode" },
unique: true,
filter: "\"TaxId13\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_person_profiles_tenant_id",
table: "person_profiles",
column: "tenant_id");
migrationBuilder.CreateIndex(
name: "IX_person_profiles_tenant_id_CustomerProfileId",
table: "person_profiles",
columns: new[] { "tenant_id", "CustomerProfileId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "customer_addresses");
migrationBuilder.DropTable(
name: "customer_contacts");
migrationBuilder.DropTable(
name: "customer_tags");
migrationBuilder.DropTable(
name: "organization_profiles");
migrationBuilder.DropTable(
name: "person_profiles");
migrationBuilder.DropTable(
name: "addresses");
migrationBuilder.DropTable(
name: "customer_profiles");
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -65,47 +65,16 @@ public class UserRepository : IUserRepository
public async Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default) public async Task<User?> FindActiveByEmailAsync(string email, CancellationToken ct = default)
{ {
var r = _redis?.GetDatabase();
var norm = email.Trim().ToLowerInvariant(); var norm = email.Trim().ToLowerInvariant();
if (r is not null)
{
try
{
var cached = await r.StringGetAsync($"uidx:{norm}");
if (cached.HasValue)
{
var hit = JsonSerializer.Deserialize<User>(cached!);
if (hit?.IsActive == true) return hit;
}
}
catch { /* ignore cache errors */ }
}
var db = _scope.Get<AppDbContext>(); var db = _scope.Get<AppDbContext>();
var user = await ( return await db.Users
from u in db.Users.AsNoTracking() .AsNoTracking()
join i in db.UserIdentities.AsNoTracking() on u.Id equals i.UserId .Include(u => u.UserRoles)
where u.IsActive .ThenInclude(ur => ur.Role)
&& i.Type == IdentityType.Email .Where(u => u.IsActive &&
&& i.Identifier == norm u.Identities.Any(i => i.Type == IdentityType.Email && i.Identifier == norm))
select u .FirstOrDefaultAsync(ct);
).FirstOrDefaultAsync(ct);
if (user is not null && r is not null)
{
try
{
var payload = JsonSerializer.Serialize(user);
var ttl = TimeSpan.FromMinutes(5);
await r.StringSetAsync($"uidx:{norm}", payload, ttl);
await r.StringSetAsync($"user:{user.Id:N}", payload, ttl);
}
catch { /* ignore cache errors */ }
}
return user;
} }
public async Task<bool> EmailExistsAsync(string email, CancellationToken ct = default) public async Task<bool> EmailExistsAsync(string email, CancellationToken ct = default)
@@ -142,11 +111,61 @@ public class UserRepository : IUserRepository
if (!string.IsNullOrWhiteSpace(email)) if (!string.IsNullOrWhiteSpace(email))
await r.StringSetAsync(IdentityEmailKey(tid.ToString(), email!), payload, ttl); await r.StringSetAsync(IdentityEmailKey(tid.ToString(), email!), payload, ttl);
} }
catch { } catch
{
}
} }
} }
public async Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary, CancellationToken ct = default) public async Task<string[]> GetRoleCodesByUserIdAsync(Guid userId, Guid tenantId, CancellationToken ct = default)
{
var r = _redis?.GetDatabase();
var cacheKey = $"urole:{tenantId:N}:{userId:N}";
// ---- cache hit ----
if (r is not null)
{
try
{
var cached = await r.StringGetAsync(cacheKey);
if (cached.HasValue)
{
var arr = JsonSerializer.Deserialize<string[]>(cached!) ?? Array.Empty<string>();
if (arr.Length > 0) return arr;
}
}
catch { /* ignore cache errors */ }
}
var db = _scope.Get<AppDbContext>();
// NOTE:
// - ไม่อ้างอิง r.TenantId เพื่อให้คอมไพล์ได้แม้ Role ยังไม่มีฟิลด์ TenantId
// - ถ้าคุณเพิ่ม Role.TenantId แล้ว และต้องการกรองตาม tenant ให้เติม .Where(role => role.TenantId == tenantId)
var roles = await db.UserRoles
.AsNoTracking()
.Where(ur => ur.UserId == userId)
.Select(ur => ur.Role)
.Where(role => role != null)
.Select(role => role!.Code) // ใช้ Code เป็นค่าของ claim "role"
.Distinct()
.ToArrayAsync(ct);
// ---- cache miss → set ----
if (r is not null)
{
try
{
await r.StringSetAsync(cacheKey, JsonSerializer.Serialize(roles), TimeSpan.FromMinutes(5));
}
catch { /* ignore cache errors */ }
}
return roles;
}
public async Task AddIdentityAsync(Guid userId, IdentityType type, string identifier, bool isPrimary,
CancellationToken ct = default)
{ {
var tid = TenantId(); var tid = TenantId();
var db = _scope.Get<AppDbContext>(); var db = _scope.Get<AppDbContext>();
@@ -162,18 +181,19 @@ public class UserRepository : IUserRepository
var entity = new UserIdentity var entity = new UserIdentity
{ {
TenantId = tid, TenantId = tid,
UserId = userId, UserId = userId,
Type = type, Type = type,
Identifier = norm, Identifier = norm,
IsPrimary = isPrimary IsPrimary = isPrimary
}; };
await db.UserIdentities.AddAsync(entity, ct); await db.UserIdentities.AddAsync(entity, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
public async Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt, CancellationToken ct = default) public async Task VerifyIdentityAsync(Guid userId, IdentityType type, string identifier, DateTimeOffset verifiedAt,
CancellationToken ct = default)
{ {
var tid = TenantId(); var tid = TenantId();
var db = _scope.Get<AppDbContext>(); var db = _scope.Get<AppDbContext>();
@@ -191,7 +211,8 @@ public class UserRepository : IUserRepository
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
public async Task<UserIdentity?> GetPrimaryIdentityAsync(Guid userId, IdentityType type, CancellationToken ct = default) public async Task<UserIdentity?> GetPrimaryIdentityAsync(Guid userId, IdentityType type,
CancellationToken ct = default)
{ {
var tid = TenantId(); var tid = TenantId();
var db = _scope.Get<AppDbContext>(); var db = _scope.Get<AppDbContext>();
@@ -234,7 +255,8 @@ public class UserRepository : IUserRepository
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
public async Task<UserMfaFactor> AddTotpFactorAsync(Guid userId, string label, string secret, CancellationToken ct = default) public async Task<UserMfaFactor> AddTotpFactorAsync(Guid userId, string label, string secret,
CancellationToken ct = default)
{ {
var tid = TenantId(); var tid = TenantId();
var db = _scope.Get<AppDbContext>(); var db = _scope.Get<AppDbContext>();
@@ -294,7 +316,8 @@ public class UserRepository : IUserRepository
return session; return session;
} }
public async Task<UserSession?> FindSessionByRefreshHashAsync(Guid tenantId, string refreshTokenHash, CancellationToken ct = default) public async Task<UserSession?> FindSessionByRefreshHashAsync(Guid tenantId, string refreshTokenHash,
CancellationToken ct = default)
{ {
var db = _scope.Get<AppDbContext>(); var db = _scope.Get<AppDbContext>();
return await db.UserSessions return await db.UserSessions
@@ -302,7 +325,8 @@ public class UserRepository : IUserRepository
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.RefreshTokenHash == refreshTokenHash, ct); .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.RefreshTokenHash == refreshTokenHash, ct);
} }
public async Task<bool> RotateSessionRefreshAsync(Guid tenantId, Guid sessionId, string newRefreshTokenHash, DateTimeOffset newIssuedAt, DateTimeOffset? newExpiresAt, CancellationToken ct = default) public async Task<bool> RotateSessionRefreshAsync(Guid tenantId, Guid sessionId, string newRefreshTokenHash,
DateTimeOffset newIssuedAt, DateTimeOffset? newExpiresAt, CancellationToken ct = default)
{ {
var db = _scope.Get<AppDbContext>(); var db = _scope.Get<AppDbContext>();
var s = await db.UserSessions.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == sessionId, ct); var s = await db.UserSessions.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == sessionId, ct);
@@ -319,7 +343,8 @@ public class UserRepository : IUserRepository
var tid = TenantId(); var tid = TenantId();
var db = _scope.Get<AppDbContext>(); var db = _scope.Get<AppDbContext>();
var s = await db.UserSessions.FirstOrDefaultAsync(x => x.TenantId == tid && x.Id == sessionId && x.UserId == userId, ct); var s = await db.UserSessions.FirstOrDefaultAsync(
x => x.TenantId == tid && x.Id == sessionId && x.UserId == userId, ct);
if (s is null) return 0; if (s is null) return 0;
s.RevokedAt = DateTimeOffset.UtcNow; s.RevokedAt = DateTimeOffset.UtcNow;